diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..21f9786 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,291 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + # paths-filter falls back to the pull request API when it cannot diff locally + pull-requests: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: "24" + +jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + console: ${{ steps.filter.outputs.console }} + worker-docker: ${{ steps.filter.outputs.worker-docker }} + worker-sys: ${{ steps.filter.outputs.worker-sys }} + worker-boxlite: ${{ steps.filter.outputs.worker-boxlite }} + web: ${{ steps.filter.outputs.web }} + website: ${{ steps.filter.outputs.website }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # api/ holds the shared proto and its generated Go module: the three Go + # modules pull it in via a replace directive and worker-boxlite compiles + # api/proto in build.rs, so a change there has to rebuild every backend. + - name: Filter changed paths + uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + shared: &shared + - 'api/**' + - '.github/workflows/ci.yml' + console: + - *shared + - 'console/**' + worker-docker: + - *shared + - 'worker/worker-docker/**' + worker-sys: + - *shared + - 'worker/worker-sys/**' + worker-boxlite: + - *shared + - 'worker/worker-boxlite/**' + web: + - '.github/workflows/ci.yml' + - 'web/**' + website: + - '.github/workflows/ci.yml' + - 'website/**' + + console: + name: Check console + needs: changes + if: needs.changes.outputs.console == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./console + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: console/go.mod + cache-dependency-path: console/go.sum + + - name: Vet + run: go vet ./... + + - name: Unit tests + run: go test ./... + + worker-docker: + name: Check worker-docker + needs: changes + if: needs.changes.outputs.worker-docker == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./worker/worker-docker + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: worker/worker-docker/go.mod + cache-dependency-path: worker/worker-docker/go.sum + + - name: Vet + run: go vet ./... + + - name: Unit tests + run: go test ./... + + worker-sys: + name: Check worker-sys + needs: changes + if: needs.changes.outputs.worker-sys == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./worker/worker-sys + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: worker/worker-sys/go.mod + cache-dependency-path: worker/worker-sys/go.sum + + - name: Vet + run: go vet ./... + + - name: Unit tests + run: go test ./... + + worker-boxlite: + name: Check worker-boxlite + needs: changes + if: needs.changes.outputs.worker-boxlite == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./worker/worker-boxlite + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # our build.rs uses protoc-bin-vendored, but the boxlite-shared build + # script needs protoc on the system + - name: Install protoc + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + protoc --version + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache cargo registry and target + uses: Swatinem/rust-cache@v2 + with: + workspaces: worker/worker-boxlite + + - name: Format check + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --all-targets + + - name: Unit tests + run: cargo test + + web: + name: Check web + needs: changes + if: needs.changes.outputs.web == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./web + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Enable Corepack + run: corepack enable + + - name: Resolve yarn cache folder + id: yarn-cache + run: echo "dir=$(yarn config get cacheFolder)" >> "${GITHUB_OUTPUT}" + + - name: Cache yarn packages + uses: actions/cache@v6 + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-web-${{ hashFiles('web/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-web- + + - name: Install dependencies + run: yarn install --immutable + + - name: Format check + run: yarn format:check + + - name: Type check + run: yarn type-check + + - name: Unit tests + run: yarn test:unit --run + + - name: Build + run: yarn build-only + + website: + name: Check website + needs: changes + if: needs.changes.outputs.website == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./website + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Enable Corepack + run: corepack enable + + - name: Resolve yarn cache folder + id: yarn-cache + run: echo "dir=$(yarn config get cacheFolder)" >> "${GITHUB_OUTPUT}" + + - name: Cache yarn packages + uses: actions/cache@v6 + with: + path: ${{ steps.yarn-cache.outputs.dir }} + key: ${{ runner.os }}-yarn-website-${{ hashFiles('website/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn-website- + + - name: Install dependencies + run: yarn install --immutable + + - name: Unit tests + run: yarn test + + # build runs tsc -b, the SSR build and the prerender step + - name: Build + run: yarn build + + # Single job to mark as required in branch protection: skipped checks count as + # success, so unrelated subprojects never block a PR. + ci-ok: + name: CI + if: always() + needs: + - changes + - console + - worker-docker + - worker-sys + - worker-boxlite + - web + - website + runs-on: ubuntu-latest + steps: + - name: Verify job results + env: + NEEDS: ${{ toJSON(needs) }} + run: | + set -euo pipefail + echo "${NEEDS}" | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + failed="$(echo "${NEEDS}" | jq -r '[to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key] | join(", ")')" + if [[ -n "${failed}" ]]; then + echo "::error::Failed or cancelled jobs: ${failed}" >&2 + exit 1 + fi diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 0000000..bd6c33e --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,74 @@ +name: deploy-website +run-name: Deploy website (${{ inputs.mode }}) from ${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + mode: + description: 'production = deploy at 100% traffic; preview = upload a version without shifting traffic' + required: true + default: production + type: choice + options: + - production + - preview + +permissions: + contents: read + +# shared with the deploy-cloudflare job in package-release.yml so a release +# deploy and a website-only deploy never race for production traffic +concurrency: + group: deploy-onlyboxes-website + cancel-in-progress: false + +jobs: + deploy-website: + name: Build and deploy website + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./website + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install --immutable + + - name: Unit tests + run: yarn test + + # build runs tsc -b, the SSR build and the prerender step + - name: Build website + run: yarn build + + - name: Deploy to production (100% traffic) + if: inputs.mode == 'production' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: "4" + workingDirectory: website + command: deploy + + - name: Upload preview version (no traffic shift) + if: inputs.mode == 'preview' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + wranglerVersion: "4" + workingDirectory: website + # version tag must stay short and free of '/', so the branch name goes in the message only + command: versions upload --tag preview-${{ github.run_number }} --message "Preview from ${{ github.ref_name }} (run ${{ github.run_number }})" diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 1ba30f7..ba8a8fe 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -1,10 +1,18 @@ name: package-release -run-name: Release ${{ github.ref_name }} +run-name: Release ${{ inputs.version }} on: - push: - tags: - - '*.*.*' + workflow_dispatch: + inputs: + version: + description: 'Release version, e.g. 0.7.2 or 0.8.0-rc.1 (tag is created from the selected branch)' + required: true + type: string + latest: + description: 'Mark as Latest. Turn off when patching an older line so the newest release keeps the label. Ignored for prereleases.' + required: false + default: true + type: boolean permissions: contents: write @@ -24,11 +32,20 @@ jobs: - name: Resolve version id: version shell: bash + env: + INPUT_VERSION: ${{ inputs.version }} run: | set -euo pipefail - version_raw="${GITHUB_REF_NAME}" + version_raw="$(printf '%s' "${INPUT_VERSION}" | tr -d '[:space:]')" if [[ -z "${version_raw}" ]]; then - echo "version is required" >&2 + echo "::error::version is required" >&2 + exit 1 + fi + + # The tag push trigger used to enforce the '*.*.*' shape; with manual + # dispatch nothing does, so validate the input here. + if [[ ! "${version_raw}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta|alpha)[.-][0-9A-Za-z.]+)?$ ]]; then + echo "::error::Invalid version '${version_raw}'. Expected 1.2.3, or a prerelease like 1.2.3-rc.1 (no leading 'v')." >&2 exit 1 fi @@ -52,8 +69,10 @@ jobs: - name: Verify release notes file shell: bash + env: + VERSION_RAW: ${{ steps.version.outputs.version_raw }} run: | - notes="docs/release_notes/${GITHUB_REF_NAME}.md" + notes="docs/release_notes/${VERSION_RAW}.md" if [[ ! -f "${notes}" ]]; then echo "::error::Release notes file not found: ${notes}" >&2 exit 1 @@ -71,6 +90,10 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.prepare-release.outputs.version_raw }} + # the tag does not exist yet and target_commitish otherwise defaults + # to the repository default branch, which would point the tag at code + # other than what this run built + target_commitish: ${{ github.sha }} name: ${{ needs.prepare-release.outputs.version_raw }} body_path: docs/release_notes/${{ needs.prepare-release.outputs.version_raw }}.md prerelease: ${{ needs.prepare-release.outputs.prerelease == 'true' }} @@ -731,6 +754,11 @@ jobs: deploy-cloudflare: name: Deploy website to Cloudflare runs-on: ubuntu-latest + # shared with deploy-website.yml so a release deploy and a website-only + # deploy never race for production traffic + concurrency: + group: deploy-onlyboxes-website + cancel-in-progress: false needs: - prepare-release - build-website @@ -785,10 +813,14 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_RAW: ${{ needs.prepare-release.outputs.version_raw }} IS_PRERELEASE: ${{ needs.prepare-release.outputs.prerelease }} + MARK_LATEST: ${{ inputs.latest }} run: | set -euo pipefail if [[ "${IS_PRERELEASE}" == "true" ]]; then - gh release edit "${VERSION_RAW}" --draft=false --prerelease --repo "${GITHUB_REPOSITORY}" - else + gh release edit "${VERSION_RAW}" --draft=false --prerelease --latest=false --repo "${GITHUB_REPOSITORY}" + elif [[ "${MARK_LATEST}" == "true" ]]; then gh release edit "${VERSION_RAW}" --draft=false --latest --repo "${GITHUB_REPOSITORY}" + else + # patching an older line: publish without stealing the Latest label + gh release edit "${VERSION_RAW}" --draft=false --latest=false --repo "${GITHUB_REPOSITORY}" fi diff --git a/.gitignore b/.gitignore index e4faa2c..58d5414 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,7 @@ worker-credentials.json # service config files (may contain secrets) worker/*/config.toml console/config.toml + +# local dev orchestration (scripts/dev.sh) +scripts/.dev/ +scripts/dev.env diff --git a/AGENTS.md b/AGENTS.md index 03ad791..711dc59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ Onlyboxes 是一个面向个人与小型团队的代码执行沙箱平台解决 - 此文件夹为项目根目录。使用 monorepo 管理多个工程,前后端分离。核心服务以 控制节点-执行节点 的形式部署。 - 所有子工程都有各自的`README`文件夹,每个 md 文件代表某个方面的说明,如果工作内容涉及对应方面,应当阅读对应 md 文件。 - 根目录的`README`文件夹用于记录跨工程的项目说明,其中`README/API.md`与`README/API.zh-CN.md`为统一 API 参考,`README/release-defaults.md`为发版默认值检查清单。发版或调整默认版本时,应阅读`README/release-defaults.md`。 +- 本地启动服务统一使用`scripts/dev.sh`(tmux 编排 console / web / website,所有子命令立即返回,不阻塞终端),不要自行拼装`go run`或`yarn dev`。用法见`scripts/README.md`。worker 不在编排范围内,需手动启动。 # 项目概述 diff --git a/README.md b/README.md index 67acec9..a082d6b 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,8 @@ Web dev URL defaults to `http://127.0.0.1:5178` and proxies `/api/*` to `http:// ## Release & Images -- GitHub workflow: `.github/workflows/package-release.yml` +- GitHub workflow: `.github/workflows/package-release.yml` — run it manually from the Actions tab, pick the branch and enter the version (e.g. `0.7.2`); the tag is created from that branch. Turn off `latest` when patching an older line so the newest release keeps the label +- Website-only deploy: `.github/workflows/deploy-website.yml` — manual, independent of a release - Console Docker image: `coolfan1024/onlyboxes:` and `coolfan1024/onlyboxes:latest` - Terminal runtime images: `coolfan1024/onlyboxes-runtime:-default`, `-default-cn`, and `-lobehub`; stable aliases are `default`, `default-cn`, `lobehub`, and `latest` (same as `default`) - Console binary includes embedded web assets diff --git a/README.zh-CN.md b/README.zh-CN.md index 39e3be9..d333cca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -232,7 +232,8 @@ yarn --cwd web dev ## 发布与镜像 -- GitHub 工作流:`.github/workflows/package-release.yml` +- GitHub 工作流:`.github/workflows/package-release.yml` —— 在 Actions 页面手动触发,选择分支并填写版本号(如 `0.7.2`),tag 会基于该分支创建。为旧版本线补发时关闭 `latest`,避免抢走最新版本的标记 +- 仅部署官网:`.github/workflows/deploy-website.yml` —— 手动触发,不依赖发版流程 - Console 镜像:`coolfan1024/onlyboxes:` 与 `coolfan1024/onlyboxes:latest` - 终端运行时镜像:`coolfan1024/onlyboxes-runtime:-default`、`-default-cn` 与 `-lobehub`;稳定别名为 `default`、`default-cn`、`lobehub` 和 `latest`(等同于 `default`) - Console 二进制已内置前端静态资源 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..7ca36b6 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,64 @@ +# scripts + +## dev.sh — 本地开发进程编排 + +用一个 tmux 会话托管 console / web / website,避免 `go run` 与 `vite dev` 阻塞终端。所有子命令都立即返回,日志同时落到 `scripts/.dev/.log`。 + +```bash +scripts/dev.sh start # 三个全起 +scripts/dev.sh start console web # 只起控制节点与前端 +scripts/dev.sh status # 会话、端口监听与窗口状态 +scripts/dev.sh logs console # 最近 200 行日志快照 +scripts/dev.sh creds # console 管理员账号 +scripts/dev.sh restart web # 重启前端 +scripts/dev.sh stop # 全部停止并销毁会话 +``` + +### 服务 + +| 服务 | 目录 | 命令 | 端口 | +| --- | --- | --- | --- | +| console | `console/` | `go run ./cmd/console` | 8089(HTTP)、50051(gRPC) | +| web | `web/` | `yarn dev` | 5178 | +| website | `website/` | `yarn dev` | 5173 | + +前端开发只需 `start console web`:`web` 的 vite 已把 `/api` 与 `/mcp` 代理到 `127.0.0.1:8089`,浏览器开 即可。 + +**不含 worker。** 各 worker 实现的启动参数差异较大,部分场景还需同时运行多个实例,请手动启动。 + +### 管理员账号 + +console 只在**首次初始化数据库**时生成管理员账号并打印一次。`creds` 会在首次抓到后固化到 `scripts/.dev/console-creds.json`(权限 600),之后可随时查看: + +```bash +scripts/dev.sh creds +``` + +若凭据已丢失,删库重建: + +```bash +scripts/dev.sh stop console +rm console/db/onlyboxes-console.db +scripts/dev.sh start console && scripts/dev.sh creds +``` + +### 配置 + +tmux server 的环境与调用方 shell 是隔离的,`FOO=bar scripts/dev.sh start` **不会生效**。服务所需配置写入 `scripts/dev.env`(不入库),start 时自动加载: + +```bash +cp scripts/dev.env.example scripts/dev.env +``` + +改动后 `restart` 对应服务生效。 + +### 说明 + +- 不提供 `attach`、`logs -f` 等阻塞入口,需要实时查看请 `tmux attach -t onlyboxes-dev` +- 服务进程退出后窗口会保留,`status` 显示「已退出」,再次 `start` 可原位复活 +- `stop` 只回收本会话窗口进程树内的进程;端口被手动启动的实例占用时会明确提示并跳过,不会误杀 +- 环境变量:`ONLYBOXES_DEV_SESSION`(会话名,默认 `onlyboxes-dev`)、`ONLYBOXES_DEV_ENV`(配置文件路径,默认 `scripts/dev.env`) + +## install.sh / install.py + +面向部署的安装脚本,与本地开发无关,见 `README/` 下的安装文档。 diff --git a/scripts/dev.env.example b/scripts/dev.env.example new file mode 100644 index 0000000..e7d0dee --- /dev/null +++ b/scripts/dev.env.example @@ -0,0 +1,26 @@ +# scripts/dev.sh 的可选配置。复制为 scripts/dev.env 后按需修改(该文件不入库)。 +# +# cp scripts/dev.env.example scripts/dev.env +# +# tmux server 与调用方 shell 的环境是隔离的,直接 `FOO=bar scripts/dev.sh start` +# 不会生效,配置必须写在这里。修改后需要 restart 对应服务。 + +# ── console ──────────────────────────────────────────────────────────── +# 监听地址(默认 :8089 / :50051) +# CONSOLE_HTTP_ADDR=:8089 +# CONSOLE_GRPC_ADDR=:50051 + +# SQLite 路径,相对 console/ 目录(默认 ./db/onlyboxes-console.db) +# CONSOLE_DB_PATH=./db/onlyboxes-console.db + +# 日志(默认 info / json)。本地调试建议 debug + text +# CONSOLE_LOG_LEVEL=debug +# CONSOLE_LOG_FORMAT=text + +# 首次初始化管理员账号时使用;账号已存在时忽略 +# CONSOLE_DASHBOARD_USERNAME=admin +# CONSOLE_DASHBOARD_PASSWORD= + +# ── web ──────────────────────────────────────────────────────────────── +# 前端代理指向的 console 地址(默认 http://127.0.0.1:8089) +# VITE_API_TARGET=http://127.0.0.1:8089 diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..c8206ba --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,487 @@ +#!/usr/bin/env bash +# Onlyboxes 本地开发进程编排:用一个 tmux 会话托管 console / web / website, +# 避免 go run 与 vite dev 阻塞终端。日志同时落到 scripts/.dev/.log。 +set -euo pipefail + +SESSION="${ONLYBOXES_DEV_SESSION:-onlyboxes-dev}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOG_DIR="$SCRIPT_DIR/.dev" +CREDS_FILE="$LOG_DIR/console-creds.json" +# tmux server 不继承调用方的自定义环境变量,因此额外配置统一走这个文件 +ENV_FILE="${ONLYBOXES_DEV_ENV:-$SCRIPT_DIR/dev.env}" + +ALL_SERVICES=(console web website) + +svc_dir() { + case "$1" in + console) echo "$ROOT_DIR/console" ;; + web) echo "$ROOT_DIR/web" ;; + website) echo "$ROOT_DIR/website" ;; + *) return 1 ;; + esac +} + +svc_cmd() { + case "$1" in + # console 默认 JSON 日志,无需处理 ANSI;两个前端关闭彩色输出,避免日志混入转义序列 + console) echo "go run ./cmd/console" ;; + web) echo "NO_COLOR=1 yarn dev" ;; + website) echo "NO_COLOR=1 yarn dev" ;; + *) return 1 ;; + esac +} + +# 读取 dev.env 中某个变量的值,不影响当前 shell 环境 +env_value() { + local key="$1" + [ -f "$ENV_FILE" ] || return 0 + sed -n "s/^[[:space:]]*${key}=//p" "$ENV_FILE" | + tail -n 1 | + sed -e 's/^["'\'']//' -e 's/["'\'']$//' +} + +# ":8089" / "127.0.0.1:8089" → 8089 +addr_port() { + printf '%s' "${1##*:}" +} + +# 监听地址可被 dev.env 覆盖,端口必须跟着走,否则 status 与端口回收都会看错地方 +configured_port() { + local key="$1" fallback="$2" addr + addr="$(env_value "$key")" + if [ -n "$addr" ]; then + addr_port "$addr" + else + printf '%s' "$fallback" + fi +} + +# 主端口:用于 status 展示与 stop 后的兜底清理 +svc_port() { + case "$1" in + console) configured_port CONSOLE_HTTP_ADDR 8089 ;; + web) echo 5178 ;; + website) echo 5173 ;; + *) echo "" ;; + esac +} + +# 需要一并回收的附加端口(console 还监听 gRPC) +svc_extra_ports() { + case "$1" in + console) configured_port CONSOLE_GRPC_ADDR 50051 ;; + *) echo "" ;; + esac +} + +# 服务命令包一层 tee:面板显示与日志落盘同源。 +# 前置 source dev.env:tmux server 环境与调用方隔离,配置只能在窗口内加载。 +# +# 显式走 bash:tmux 用 default-shell 执行窗口命令,若开发者用的是 fish 之类的 +# 非 POSIX shell,下面的 set -a / . file 会失效。内层只用双引号,整体才能安全地 +# 被单引号包住,交给任意 shell 解析都是同一个字面量。 +wrapped_cmd() { + local inner + inner="set -a; [ -f \"$ENV_FILE\" ] && . \"$ENV_FILE\"; set +a; $(svc_cmd "$1") 2>&1 | tee \"$LOG_DIR/$1.log\"" + echo "bash -c '$inner'" +} + +die() { + echo "错误:$*" >&2 + exit 1 +} + +require_tmux() { + command -v tmux >/dev/null 2>&1 || die "未找到 tmux,请先安装(brew install tmux)" +} + +is_valid_service() { + local svc="$1" + for s in "${ALL_SERVICES[@]}"; do + [ "$svc" = "$s" ] && return 0 + done + return 1 +} + +# 解析服务参数:为空则返回全部,否则逐个校验 +resolve_services() { + if [ "$#" -eq 0 ]; then + printf '%s\n' "${ALL_SERVICES[@]}" + return + fi + for svc in "$@"; do + is_valid_service "$svc" || die "未知服务 '$svc'(可选:${ALL_SERVICES[*]})" + echo "$svc" + done +} + +session_exists() { + tmux has-session -t "$SESSION" 2>/dev/null +} + +window_exists() { + tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qx "$1" +} + +port_listening() { + local port="$1" + [ -n "$port" ] || return 1 + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 +} + +# 递归列出某进程及其所有子孙的 PID +descendant_pids() { + local pid="$1" child + [ -n "$pid" ] || return 0 + echo "$pid" + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + descendant_pids "$child" + done +} + +# 窗口进程树快照:必须在 kill-window 之前采集 +snapshot_pids() { + local svc="$1" pane_pid + pane_pid="$(tmux list-panes -t "$SESSION:$svc" -F '#{pane_pid}' 2>/dev/null | head -n 1 || true)" + [ -n "$pane_pid" ] || return 0 + descendant_pids "$pane_pid" +} + +# go run 会 fork 出真正的二进制,tmux 杀窗口后子进程可能残留并占住端口, +# 因此停止服务后做一次兜底回收。 +# +# 只回收「既占用该端口、又属于本会话窗口进程树」的 PID:端口上的进程可能是 +# 用户手动启动的服务,无差别按端口 kill 会误杀本脚本管辖之外的进程。 +reclaim_ports() { + local svc="$1" owned_pids="$2" port + [ -n "$owned_pids" ] || return 0 + + for port in $(svc_port "$svc") $(svc_extra_ports "$svc"); do + [ -n "$port" ] || continue + port_listening "$port" || continue + + local port_pids targets + # 只取监听者:不加 -sTCP:LISTEN 会把连到该端口的客户端进程也算进来 + port_pids="$(lsof -ti tcp:"$port" -sTCP:LISTEN 2>/dev/null || true)" + [ -n "$port_pids" ] || continue + + # 取交集:仅本窗口进程树内的 PID + targets="$(comm -12 \ + <(printf '%s\n' "$port_pids" | sort -u) \ + <(printf '%s\n' "$owned_pids" | sort -u))" + if [ -z "$targets" ]; then + echo "• 端口 $port 被本脚本之外的进程占用,未做处理(lsof -i tcp:${port})" + continue + fi + + # shellcheck disable=SC2086 + kill $targets 2>/dev/null || true + for _ in 1 2 3 4 5 6 7 8 9 10; do + # shellcheck disable=SC2086 + kill -0 $targets 2>/dev/null || break + sleep 0.3 + done + # shellcheck disable=SC2086 + kill -9 $targets 2>/dev/null || true + + echo "• 已回收 $svc 在端口 $port 的残留进程" + done +} + +ensure_session() { + if session_exists; then + return + fi + # 先建一个占位 window,待服务 window 建好后再移除 + tmux new-session -d -s "$SESSION" -n __bootstrap__ -c "$ROOT_DIR" +} + +cleanup_bootstrap() { + if window_exists __bootstrap__; then + tmux kill-window -t "$SESSION:__bootstrap__" 2>/dev/null || true + fi +} + +start_service() { + local svc="$1" dir log port + dir="$(svc_dir "$svc")" + log="$LOG_DIR/$svc.log" + + if window_exists "$svc"; then + if tmux list-panes -t "$SESSION:$svc" -F '#{pane_dead}' 2>/dev/null | grep -qx 1; then + # 进程已退出(窗口因 remain-on-exit 残留),原位复活 + : >"$log" + tmux respawn-window -k -t "$SESSION:$svc" -c "$dir" "$(wrapped_cmd "$svc")" + echo "• $svc 进程已退出,已在原窗口重启(端口 $(svc_port "$svc"),日志 scripts/.dev/$svc.log)" + else + echo "• $svc 已在运行,跳过" + fi + return + fi + + port="$(svc_port "$svc")" + if port_listening "$port"; then + echo "• 警告:端口 $port 已被其他进程占用,$svc 可能启动失败" >&2 + fi + + mkdir -p "$LOG_DIR" + : >"$log" + + # 服务命令直接作为窗口进程运行:进程退出即 pane 死亡,status 能准确反映状态。 + # 输出经 tee 落盘:stdout 非 TTY 时 vite 自动退化为纯文本行式输出, + # 日志不会混入屏幕重绘/光标移动转义序列 + tmux new-window -t "$SESSION" -n "$svc" -c "$dir" "$(wrapped_cmd "$svc")" + # 进程退出后保留 pane,便于 status 显示「已退出」并支持原位复活 + tmux set-window-option -t "$SESSION:$svc" remain-on-exit on + echo "• ${svc} 启动中(端口 ${port},日志 scripts/.dev/$svc.log)" +} + +stop_service() { + local svc="$1" owned + if ! window_exists "$svc"; then + echo "• $svc 未在运行" + return + fi + # 进程树快照必须先于 kill-window 采集,否则无从判断端口上的进程是否归本脚本管 + owned="$(snapshot_pids "$svc")" + tmux kill-window -t "$SESSION:$svc" + echo "• $svc 已停止" + reclaim_ports "$svc" "$owned" +} + +cmd_start() { + require_tmux + local services + services=$(resolve_services "$@") + ensure_session + while IFS= read -r svc; do + start_service "$svc" + done <<<"$services" + cleanup_bootstrap + echo + echo "已就绪。状态:scripts/dev.sh status | 日志:scripts/dev.sh logs | 控制台账号:scripts/dev.sh creds" +} + +cmd_stop() { + require_tmux + local services + services=$(resolve_services "$@") + + if ! session_exists; then + echo "会话 $SESSION 未运行" + return + fi + + if [ "$#" -eq 0 ]; then + # 先逐个采集进程树快照,再销毁会话,最后按快照回收 + local snapshots="" svc_snapshot + while IFS= read -r svc; do + window_exists "$svc" || continue + svc_snapshot="$(snapshot_pids "$svc" | tr '\n' ' ')" + snapshots+="$svc|$svc_snapshot"$'\n' + done <<<"$services" + + tmux kill-session -t "$SESSION" + echo "已停止全部服务(会话 $SESSION 已销毁)" + + while IFS='|' read -r svc pids; do + [ -n "$svc" ] || continue + # shellcheck disable=SC2086 + reclaim_ports "$svc" "$(printf '%s\n' $pids)" + done <<<"$snapshots" + return + fi + + while IFS= read -r svc; do + stop_service "$svc" + done <<<"$services" + + # 若只剩占位或空会话则一并销毁 + if ! tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qvx __bootstrap__; then + tmux kill-session -t "$SESSION" 2>/dev/null || true + fi +} + +cmd_restart() { + cmd_stop "$@" + sleep 1 + cmd_start "$@" +} + +cmd_status() { + require_tmux + if ! session_exists; then + echo "会话 ${SESSION}:未运行" + return + fi + echo "会话 ${SESSION}:运行中" + # 表头含中文,显示宽度与 printf 的字符计数不一致,这里按显示宽度手工对齐 + echo "服务 端口 监听 窗口状态" + for svc in "${ALL_SERVICES[@]}"; do + local port extra ports listen win all_listening + port="$(svc_port "$svc")" + extra="$(svc_extra_ports "$svc")" + ports="$port${extra:+/$extra}" + + all_listening=1 + for p in $port $extra; do + port_listening "$p" || all_listening=0 + done + + if window_exists "$svc"; then + if tmux list-panes -t "$SESSION:$svc" -F '#{pane_dead}' 2>/dev/null | grep -qx 1; then + win="已退出" + else + win="运行中" + fi + else + win="未启动" + fi + + if [ "$all_listening" -eq 1 ]; then + listen="LISTEN" + # 本脚本没起这个服务,端口却在监听:多半是手动启动的实例,明确标注以免误读 + [ "$win" = "运行中" ] || win="${win}(端口被外部进程占用)" + else + listen="-" + fi + printf '%-10s %-14s %-8s %s\n' "$svc" "$ports" "$listen" "$win" + done +} + +cmd_logs() { + local svc="${1:-}" + [ -n "$svc" ] || die "用法:scripts/dev.sh logs <${ALL_SERVICES[*]}>" + is_valid_service "$svc" || die "未知服务 '$svc'(可选:${ALL_SERVICES[*]})" + local log="$LOG_DIR/$svc.log" + [ -f "$log" ] || die "暂无 ${svc} 日志(先执行 scripts/dev.sh start ${svc})" + tail -n 200 "$log" +} + +# 从一行日志里取字段,兼容 CONSOLE_LOG_FORMAT 的两种取值: +# json 的 "key":"value",text 的 key=value 与 key="value" +log_field() { + local line="$1" key="$2" value + value="$(printf '%s' "$line" | sed -n "s/.*\"$key\":\"\([^\"]*\)\".*/\1/p")" + if [ -z "$value" ]; then + value="$(printf '%s' "$line" | sed -n "s/.*[[:space:]]$key=\"\([^\"]*\)\".*/\1/p")" + fi + if [ -z "$value" ]; then + value="$(printf '%s' "$line" | sed -n "s/.*[[:space:]]$key=\([^[:space:]]*\).*/\1/p")" + fi + printf '%s' "$value" +} + +print_creds() { + local line="$1" username password api_key_name api_key + username="$(log_field "$line" username)" + password="$(log_field "$line" password)" + api_key_name="$(log_field "$line" api_key_name)" + api_key="$(log_field "$line" api_key)" + + echo "console 管理员账号(首次初始化时生成)" + echo " 用户名:$username" + echo " 密码: $password" + if [ -n "$api_key" ]; then + echo " API Key(${api_key_name}):$api_key" + fi + echo + echo "登录地址:http://localhost:5178" +} + +# console 只在首次初始化时把管理员密码打进日志,而 start 每次会清空日志, +# 因此首次抓到后固化到 scripts/.dev/console-creds.json 长期保留。 +cmd_creds() { + local line="" + if [ -f "$CREDS_FILE" ]; then + line="$(cat "$CREDS_FILE")" + else + local log="$LOG_DIR/console.log" + if [ -f "$log" ]; then + line="$(grep -F 'console admin account initialized' "$log" | tail -n 1 || true)" + fi + fi + + if [ -n "$line" ]; then + # 只有真正解析出用户名和密码才落盘:密码仅打印一次,缓存了解析不了的行 + # 就等于永久丢失 + if [ -n "$(log_field "$line" username)" ] && [ -n "$(log_field "$line" password)" ]; then + if [ ! -f "$CREDS_FILE" ]; then + mkdir -p "$LOG_DIR" + printf '%s\n' "$line" >"$CREDS_FILE" + chmod 600 "$CREDS_FILE" + fi + print_creds "$line" + return + fi + + echo "无法从下面这行日志中解析出管理员凭据:" >&2 + printf '%s\n' "$line" >&2 + exit 1 + fi + + cat >&2 < [服务...] + +命令: + start [svc...] 后台启动服务(默认 console web website),终端不阻塞 + stop [svc...] 停止指定服务;不带参数销毁整个会话 + restart [svc...] 重启指定服务 + status 查看会话、端口监听与各窗口状态 + logs 查看某服务最近日志(快照,立即返回) + creds 打印 console 管理员账号(首次初始化时生成) + +说明:不提供 attach / logs -f 等阻塞入口;如需实时查看请自行执行 tmux attach -t $SESSION + +服务:console(:8089 HTTP,:50051 gRPC)web(:5178)website(:5173) + +不含 worker:worker 各实现启动参数差异较大,部分场景需同时运行多个实例,请手动启动。 + +示例: + scripts/dev.sh start # 三个全起 + scripts/dev.sh start console web # 只起控制节点与前端 + scripts/dev.sh logs console # 查看控制节点最近日志 + scripts/dev.sh restart web # 重启前端 + scripts/dev.sh stop # 全部停止 + +环境变量: + ONLYBOXES_DEV_SESSION 自定义 tmux 会话名(默认 onlyboxes-dev) + ONLYBOXES_DEV_ENV 自定义配置文件路径(默认 scripts/dev.env) + +配置:tmux 不继承当前 shell 的自定义环境变量,服务所需配置请写入 + scripts/dev.env(参考 scripts/dev.env.example),start 时自动加载。 +EOF +} + +main() { + local cmd="${1:-}" + [ "$#" -gt 0 ] && shift || true + case "$cmd" in + start) cmd_start "$@" ;; + stop) cmd_stop "$@" ;; + restart) cmd_restart "$@" ;; + status) cmd_status "$@" ;; + logs) cmd_logs "$@" ;; + creds) cmd_creds "$@" ;; + "" | -h | --help | help) usage ;; + *) die "未知命令 '$cmd'(执行 scripts/dev.sh --help 查看用法)" ;; + esac +} + +main "$@" diff --git a/scripts/e2e-session-concurrency.sh b/scripts/e2e-session-concurrency.sh deleted file mode 100755 index 58c8f59..0000000 --- a/scripts/e2e-session-concurrency.sh +++ /dev/null @@ -1,276 +0,0 @@ -#!/usr/bin/env bash -# End-to-end verification of per-session command concurrency. -# -# Brings up console plus one worker and checks the behaviour matrix: -# 1. default limit (1) stays strictly serial and returns 409 session_busy -# 2. a raised limit runs commands in one session genuinely in parallel -# 3. concurrent requests on a brand-new session_id create exactly one sandbox -# (the container/box creation race) -# 4. concurrent commands share the session filesystem -# 5. one command's timeout does not kill its siblings -# 6. per-session 409 and worker-level 429 stay distinct -# 7. the janitor reclaims the session only after in-flight commands drain -# -# Usage: -# scripts/e2e-session-concurrency.sh [docker|boxlite] -# -# Requires: a built console and worker binary, plus docker (for the docker -# worker) or a boxlite-capable host. -# -# Note: terminalResource has no REST route (it is exposed through MCP), so -# exec/resource concurrency is covered by the worker unit tests instead. -set -uo pipefail - -FLAVOR="${1:-docker}" -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -WORKDIR="$(mktemp -d /tmp/obx-e2e-XXXXXX)" -HTTP=http://127.0.0.1:8089 -COOKIES="$WORKDIR/cookies.txt" -CONSOLE_BIN="$WORKDIR/console" -PASS=0 -FAIL=0 -CONSOLE_PID="" -WORKER_PID="" - -case "$FLAVOR" in - docker) WORKER_SRC="$ROOT/worker/worker-docker"; WORKER_BIN="$WORKDIR/worker" ;; - boxlite) WORKER_SRC="$ROOT/worker/worker-boxlite"; WORKER_BIN="$WORKER_SRC/target/debug/worker-boxlite" ;; - *) echo "usage: $0 [docker|boxlite]"; exit 2 ;; -esac - -log() { printf '\n=== %s\n' "$*"; } -ok() { printf ' PASS %s\n' "$*"; PASS=$((PASS+1)); } -bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); } -chk() { if [ "$1" = true ]; then ok "$2"; else bad "$2 -- $3"; fi; } - -cleanup() { - [ -n "$WORKER_PID" ] && kill -9 "$WORKER_PID" 2>/dev/null - [ -n "$CONSOLE_PID" ] && kill -9 "$CONSOLE_PID" 2>/dev/null - if [ "$FLAVOR" = docker ]; then - ids=$(docker ps -aq --filter "label=onlyboxes.capability=terminalExec" 2>/dev/null) - [ -n "$ids" ] && docker rm -f $ids >/dev/null 2>&1 - fi - printf '\n%d passed, %d failed\n' "$PASS" "$FAIL" - [ "$FAIL" -eq 0 ] || exit 1 -} -trap cleanup EXIT - -# post — records HTTP status in .code, body in .body -post() { - curl -s -m "${CURL_TIMEOUT:-120}" -o "$WORKDIR/$1.body" -w '%{http_code}' \ - -X POST "$HTTP/api/v1/commands/terminal" \ - -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ - -d "$2" > "$WORKDIR/$1.code" -} -code() { cat "$WORKDIR/$1.code" 2>/dev/null; } -body() { cat "$WORKDIR/$1.body" 2>/dev/null; } -jget() { python3 -c "import json,sys;print(json.load(open('$WORKDIR/$1.body')).get('$2',''))" 2>/dev/null; } - -# Wait only on the given PIDs. A bare `wait` would also block on the worker, -# which runs in the background for the whole script. -wait_pids() { for p in "$@"; do wait "$p" 2>/dev/null; done; } - -log "building" -( cd "$ROOT/console" && go build -o "$CONSOLE_BIN" ./cmd/console ) || exit 1 -if [ "$FLAVOR" = docker ]; then - ( cd "$WORKER_SRC" && go build -o "$WORKER_BIN" ./cmd/worker-docker ) || exit 1 -else - ( cd "$WORKER_SRC" && cargo build ) || exit 1 -fi -ok "binaries built" - -log "starting console" -mkdir -p "$WORKDIR/db" -CONSOLE_HASH_KEY=e2e-hash-key-0123456789 \ -CONSOLE_DB_PATH="$WORKDIR/db/console.db" \ -CONSOLE_DASHBOARD_USERNAME=admin \ -CONSOLE_DASHBOARD_PASSWORD=admin-password \ -CONSOLE_LOG_FORMAT=text \ -"$CONSOLE_BIN" >"$WORKDIR/console.log" 2>&1 & -CONSOLE_PID=$! -for _ in $(seq 1 60); do - curl -s -o /dev/null "$HTTP/api/v1/console/session" && break - sleep 0.5 -done -ok "console listening" - -curl -s -c "$COOKIES" -X POST "$HTTP/api/v1/console/login" \ - -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"admin-password"}' >"$WORKDIR/login.json" -grep -q '"authenticated":true' "$WORKDIR/login.json" || { cat "$WORKDIR/login.json"; exit 1; } - -TOKEN=$(curl -s -b "$COOKIES" -X POST "$HTTP/api/v1/console/tokens" \ - -H 'Content-Type: application/json' -d '{"name":"e2e"}' \ - | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])") -[ -n "$TOKEN" ] || { echo "token mint failed"; exit 1; } - -curl -s -b "$COOKIES" -X POST "$HTTP/api/v1/workers" \ - -H 'Content-Type: application/json' -d '{"type":"normal"}' >"$WORKDIR/worker.json" -NODE_ID=$(python3 -c "import json;print(json.load(open('$WORKDIR/worker.json'))['node_id'])") -SECRET=$(python3 -c "import json;print(json.load(open('$WORKDIR/worker.json'))['worker_secret'])") -ok "worker provisioned: $NODE_ID" - -# start_worker -start_worker() { - if [ -n "$WORKER_PID" ]; then - kill -9 "$WORKER_PID" 2>/dev/null - # Reap it so bash does not print a job-control notice later. - wait "$WORKER_PID" 2>/dev/null - WORKER_PID="" - sleep 2 - fi - if [ "$FLAVOR" = docker ]; then - ids=$(docker ps -aq --filter "label=onlyboxes.capability=terminalExec" 2>/dev/null) - [ -n "$ids" ] && docker rm -f $ids >/dev/null 2>&1 - fi - env WORKER_CONSOLE_GRPC_TARGET=127.0.0.1:50051 WORKER_CONSOLE_INSECURE=true \ - WORKER_ID="$NODE_ID" WORKER_SECRET="$SECRET" \ - WORKER_HEARTBEAT_INTERVAL_SEC=5 \ - WORKER_TERMINAL_SESSION_MAX_INFLIGHT="$1" \ - WORKER_TERMINAL_EXEC_MAX_INFLIGHT="$2" \ - WORKER_TERMINAL_RESOURCE_MAX_INFLIGHT="$2" \ - WORKER_TERMINAL_LEASE_MIN_SEC=5 \ - WORKER_TERMINAL_LEASE_DEFAULT_SEC="$3" \ - WORKER_BOXLITE_HOME="$WORKDIR/boxlite" \ - WORKER_LOG_FORMAT=text \ - "$WORKER_BIN" >"$WORKDIR/worker-$1-$2.log" 2>&1 & - WORKER_PID=$! - sleep 8 -} - -########################################################## -log "1. default per-session limit stays serial" -########################################################## -start_worker 1 8 60 -post seed '{"command":"echo seed","timeout_ms":120000}' -S=$(jget seed session_id) -chk "$([ "$(code seed)" = 200 ] && [ -n "$S" ] && echo true || echo false)" \ - "session created" "code=$(code seed) body=$(body seed)" - -post slow "{\"command\":\"sleep 6; echo slow\",\"session_id\":\"$S\",\"timeout_ms\":120000}" & -SLOW=$! -sleep 2 -post second "{\"command\":\"echo second\",\"session_id\":\"$S\",\"timeout_ms\":30000}" -chk "$([ "$(code second)" = 409 ] && echo true || echo false)" \ - "concurrent request rejected 409 at default limit" "code=$(code second)" -body second | grep -qi busy && ok "409 body reports session busy" || bad "expected busy message, got $(body second)" -wait_pids $SLOW -chk "$([ "$(code slow)" = 200 ] && echo true || echo false)" "in-flight command still succeeded" "code=$(code slow)" - -########################################################## -log "2. raised limit runs one session's commands in parallel" -########################################################## -start_worker 4 8 60 -post seed2 '{"command":"echo seed","timeout_ms":120000}' -S=$(jget seed2 session_id) - -START=$(date +%s) -PIDS="" -for i in 1 2 3; do - post "par$i" "{\"command\":\"sleep 4; echo done-$i\",\"session_id\":\"$S\",\"timeout_ms\":120000}" & - PIDS="$PIDS $!" -done -wait_pids $PIDS -ELAPSED=$(( $(date +%s) - START )) -allok=true -for i in 1 2 3; do - [ "$(code par$i)" = 200 ] || allok=false - body "par$i" | grep -q "done-$i" || allok=false -done -chk "$allok" "3 concurrent commands all succeeded in one session" "codes: $(code par1) $(code par2) $(code par3)" -chk "$([ "$ELAPSED" -lt 10 ] && echo true || echo false)" \ - "commands ran in parallel (${ELAPSED}s < 12s serial)" "elapsed=${ELAPSED}s" - -########################################################## -log "3. sandbox creation race: concurrent requests on a new session_id" -########################################################## -count_sandboxes() { - if [ "$FLAVOR" = docker ]; then - docker ps -aq --filter "label=onlyboxes.capability=terminalExec" | wc -l | tr -d ' ' - else - ls "$WORKDIR/boxlite/boxes" 2>/dev/null | wc -l | tr -d ' ' - fi -} -BEFORE=$(count_sandboxes) -RID="race-$RANDOM$RANDOM" -PIDS="" -for i in 1 2 3 4; do - post "race$i" "{\"command\":\"echo r-$i\",\"session_id\":\"$RID\",\"create_if_missing\":true,\"timeout_ms\":300000}" & - PIDS="$PIDS $!" -done -wait_pids $PIDS -raceok=true -for i in 1 2 3 4; do - case "$(code race$i)" in 200|409) ;; *) raceok=false ;; esac -done -chk "$raceok" "every racing request got a definite answer" \ - "codes: $(code race1) $(code race2) $(code race3) $(code race4)" -AFTER=$(count_sandboxes) -chk "$([ $((AFTER-BEFORE)) -eq 1 ] && echo true || echo false)" \ - "exactly one sandbox created for the racing session" "delta=$((AFTER-BEFORE))" - -########################################################## -log "4. concurrent commands share the session filesystem" -########################################################## -post w "{\"command\":\"mkdir -p /tmp/cc && echo shared-payload > /tmp/cc/f\",\"session_id\":\"$S\",\"timeout_ms\":60000}" -post r "{\"command\":\"cat /tmp/cc/f\",\"session_id\":\"$S\",\"timeout_ms\":60000}" -body r | grep -q shared-payload && ok "filesystem shared across commands" || bad "got $(body r)" - -########################################################## -log "5. one command's timeout spares its siblings" -########################################################## -post sib "{\"command\":\"sleep 8; echo survivor\",\"session_id\":\"$S\",\"timeout_ms\":120000}" & -SIB=$! -sleep 2 -post doom "{\"command\":\"sleep 60\",\"session_id\":\"$S\",\"timeout_ms\":3000}" -chk "$([ "$(code doom)" != 200 ] && echo true || echo false)" \ - "short-deadline command failed" "code=$(code doom) body=$(body doom)" -wait_pids $SIB -sibok=false -[ "$(code sib)" = 200 ] && body sib | grep -q survivor && sibok=true -chk "$sibok" "sibling survived the other command's timeout" "code=$(code sib) body=$(body sib)" - -########################################################## -log "6. per-session 409 vs worker-level 429" -########################################################## -start_worker 4 1 60 # session allows 4, worker-level capability quota only 1 -post seed3 '{"command":"echo seed","timeout_ms":120000}' -S3=$(jget seed3 session_id) -post hold "{\"command\":\"sleep 5; echo hold\",\"session_id\":\"$S3\",\"timeout_ms\":60000}" & -HOLD=$! -sleep 2 -post over "{\"command\":\"echo over\",\"session_id\":\"$S3\",\"timeout_ms\":30000}" -chk "$([ "$(code over)" = 429 ] && echo true || echo false)" \ - "worker-level quota exhaustion returns 429 no_capacity" "code=$(code over) body=$(body over)" -wait_pids $HOLD - -########################################################## -log "7. janitor reclaims only after in-flight commands drain" -########################################################## -start_worker 4 8 6 # 6s lease, janitor ticks every 5s -post seed4 '{"command":"echo seed","lease_ttl_sec":6,"timeout_ms":120000}' -S4=$(jget seed4 session_id) -PIDS="" -for i in 1 2 3; do - post "jan$i" "{\"command\":\"sleep 3; echo j-$i\",\"session_id\":\"$S4\",\"lease_ttl_sec\":6,\"timeout_ms\":60000}" & - PIDS="$PIDS $!" -done -sleep 5 -DURING=$(count_sandboxes) -chk "$([ "$DURING" -ge 1 ] && echo true || echo false)" \ - "session not reclaimed while commands are in flight" "sandboxes=$DURING" -wait_pids $PIDS -sleep 18 -AFTER=$(count_sandboxes) -chk "$([ "$AFTER" -eq 0 ] && echo true || echo false)" \ - "session reclaimed after drain and lease expiry" "sandboxes=$AFTER" - -log "8. worker logs clean" -if grep -qiE "panic|DATA RACE" "$WORKDIR"/worker-*.log; then - bad "worker log contains panic or data race" -else - ok "no panics or data races in worker logs" -fi - -echo -echo "artifacts in $WORKDIR" diff --git a/web/package.json b/web/package.json index faa13c5..b4d62be 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "test:unit": "vitest", "build-only": "vite build", "type-check": "vue-tsc --build", - "format": "oxfmt src/" + "format": "oxfmt src/", + "format:check": "oxfmt --check src/" }, "dependencies": { "@tailwindcss/vite": "^4.2.2", diff --git a/web/src/composables/useViewTransitions.ts b/web/src/composables/useViewTransitions.ts index 552ea7e..d6a756f 100644 --- a/web/src/composables/useViewTransitions.ts +++ b/web/src/composables/useViewTransitions.ts @@ -57,13 +57,25 @@ export function useViewTransitions(router: Router): void { // earns its keep. document.documentElement.classList.add('vt-owns-entrance') + // The sidebar item animates its own background over 200ms when it stops + // being active. That tween is still near its starting colour when the new + // snapshot is taken, which freezes a dark background into the item being + // left — it then reappears the moment the transition ends and the live DOM + // takes over. Freezing those tweens for the duration hands the hand-off + // entirely to the transition. + document.documentElement.classList.add('vt-running') + return new Promise((releaseNavigation) => { - ;(document as ViewTransitionDocument).startViewTransition!(() => { + const transition = (document as ViewTransitionDocument).startViewTransition!(() => { releaseNavigation() return new Promise((done) => { finishTransition = done }) }) + + transition.finished.finally(() => { + document.documentElement.classList.remove('vt-running') + }) }) }) @@ -81,5 +93,6 @@ export function useViewTransitions(router: Router): void { router.onError(() => { finishTransition?.() finishTransition = null + document.documentElement.classList.remove('vt-running') }) } diff --git a/web/src/style/main.css b/web/src/style/main.css index 93051ea..66d53de 100644 --- a/web/src/style/main.css +++ b/web/src/style/main.css @@ -534,6 +534,20 @@ tbody tr:last-child .ui-td { view-transition-name: console-sidebar; } +/* + * Both sidebar snapshots are static for the whole transition, so any tween + * running inside the sidebar gets frozen at whatever value it held when the + * snapshot was taken. The nav item leaving the active state is the visible + * case: its 200ms background tween has barely started by then, so the item + * would sit there dark until the live DOM takes over and drops it in one + * frame. Freeze those tweens instead — the highlight hand-off is the + * transition's job. + */ +.vt-running .console-sidebar, +.vt-running .console-sidebar * { + transition: none; +} + ::view-transition-old(console-sidebar), ::view-transition-new(console-sidebar) { animation: none; @@ -566,8 +580,7 @@ tbody tr:last-child .ui-td { /* * The highlight is a single element handed between nav items, so the browser - * interpolates its box rather than fading two copies. Only the group needs a - * duration — the old/new snapshots stay pinned on top of each other. + * interpolates its box rather than fading two copies. */ ::view-transition-group(nav-active) { animation-duration: 320ms; @@ -576,16 +589,45 @@ tbody tr:last-child .ui-td { ::view-transition-old(nav-active), ::view-transition-new(nav-active) { - animation: none; mix-blend-mode: normal; height: 100%; } +/* + * The outgoing label stays fully opaque underneath: it is what the highlight + * shows at the start of the slide, where the box still sits on the item being + * left. Pinning both snapshots opaque instead would put the incoming label + * there from the first frame, so the item being left would appear to blink into + * a black box carrying another section's name. + * + * Keeping this layer solid also means the black fill never thins out mid-slide, + * which a plain cross-fade between two translucent copies would do. + */ +::view-transition-old(nav-active) { + animation: none; +} + +::view-transition-new(nav-active) { + animation-name: nav-label-fade-in; + animation-duration: 320ms; + animation-timing-function: var(--ease-emphasized); +} + ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 220ms; } +@keyframes nav-label-fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + @keyframes page-fade-out { from { opacity: 1; diff --git a/worker/worker-boxlite/README/overview.md b/worker/worker-boxlite/README/overview.md index 148be3c..eef3dc5 100644 --- a/worker/worker-boxlite/README/overview.md +++ b/worker/worker-boxlite/README/overview.md @@ -171,7 +171,3 @@ Backend smoke test: - `cargo run --example boxlite_smoke` exercises the boxlite API directly, without `console` or gRPC: runtime init, box create/start, exec, exit codes and stderr, filesystem state across executions, concurrent execution on a single box, `kill()` isolation between executions, `copy_out`, removal, and shutdown. - environment overrides: `BOXLITE_SMOKE_IMAGE` (default `alpine:latest`), `BOXLITE_SMOKE_HOME` (default a fresh temp dir), `BOXLITE_SMOKE_CONCURRENCY` (default `4`), `BOXLITE_SMOKE_KEEP_HOME`. - point `BOXLITE_SMOKE_HOME` at an existing boxlite home to exercise on-disk database migrations after a dependency upgrade. - -Session concurrency end-to-end check: -- `scripts/e2e-session-concurrency.sh boxlite` brings up console plus this worker and verifies the concurrency matrix, including that concurrent requests on one brand-new `session_id` create exactly one box rather than racing on an empty `box_id`. -- the first run downloads the terminal image into the boxlite home, which can take several minutes; warm one box before timing anything. diff --git a/worker/worker-docker/README/overview.md b/worker/worker-docker/README/overview.md index c65f2f3..7bc0988 100644 --- a/worker/worker-docker/README/overview.md +++ b/worker/worker-docker/README/overview.md @@ -127,6 +127,3 @@ Capability concurrency: Recommended setting: - `WORKER_CALL_TIMEOUT_SEC >= 2 * WORKER_HEARTBEAT_INTERVAL_SEC` - -Session concurrency end-to-end check: -- `scripts/e2e-session-concurrency.sh docker` brings up console plus this worker and verifies the concurrency matrix: default serial behaviour with `409 session_busy`, parallel execution at a raised limit, single-container creation under a concurrent session race, shared filesystem, sibling survival across a command timeout, `409` versus `429` quota levels, and janitor reclamation after in-flight commands drain.