From 964293e0006c27f46152a71b4ba3f05ae81cb8bd Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:27:27 +0800 Subject: [PATCH 1/8] chore(scripts): add tmux-based local dev orchestration Add scripts/dev.sh to run console, web and website in a single tmux session with start/stop/restart/status/logs/creds subcommands. Every subcommand returns immediately; logs are tee'd to scripts/.dev/. Port reclamation after stop only targets PIDs that both listen on the port and belong to the window process tree, so manually started instances are never killed. Service configuration goes through scripts/dev.env because the tmux server does not inherit the caller's environment. Workers are intentionally out of scope: their launch parameters differ per implementation and some scenarios need several instances at once. --- .gitignore | 4 + AGENTS.md | 1 + scripts/README.md | 64 ++++++ scripts/dev.env.example | 26 +++ scripts/dev.sh | 436 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 531 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/dev.env.example create mode 100755 scripts/dev.sh 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/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..4e08f77 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,436 @@ +#!/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 +} + +# 主端口:用于 status 展示与 stop 后的兜底清理 +svc_port() { + case "$1" in + console) echo 8089 ;; + web) echo 5178 ;; + website) echo 5173 ;; + *) echo "" ;; + esac +} + +# 需要一并回收的附加端口(console 还监听 gRPC) +svc_extra_ports() { + case "$1" in + console) echo 50051 ;; + *) echo "" ;; + esac +} + +# 服务命令包一层 tee:面板显示与日志落盘同源。 +# 前置 source dev.env:tmux server 环境与调用方隔离,配置只能在窗口内加载。 +wrapped_cmd() { + echo "set -a; [ -f '$ENV_FILE' ] && . '$ENV_FILE'; set +a; $(svc_cmd "$1") 2>&1 | tee '$LOG_DIR/$1.log'" +} + +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" +} + +# 从扁平 JSON 日志行里取一个字符串字段 +json_field() { + printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" +} + +print_creds() { + local line="$1" username password api_key_name api_key + username="$(json_field "$line" username)" + password="$(json_field "$line" password)" + api_key_name="$(json_field "$line" api_key_name)" + api_key="$(json_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() { + if [ -f "$CREDS_FILE" ]; then + print_creds "$(cat "$CREDS_FILE")" + return + fi + + local log="$LOG_DIR/console.log" line + if [ -f "$log" ]; then + line="$(grep -F 'console admin account initialized' "$log" | tail -n 1 || true)" + if [ -n "$line" ]; then + mkdir -p "$LOG_DIR" + printf '%s\n' "$line" >"$CREDS_FILE" + chmod 600 "$CREDS_FILE" + print_creds "$line" + return + fi + 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 "$@" From 60f80ef8f08fa5c08c54920dfbead52ac3ee9d42 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:27:33 +0800 Subject: [PATCH 2/8] ci: run release from manual dispatch and split out website deploy package-release now triggers via workflow_dispatch with a version input instead of a tag push. The tag filter used to enforce the version shape, so an explicit regex check replaces it; the tag itself is created from the selected branch by the draft release step. Add deploy-website for deploying the site without cutting a release, with a mode input selecting between a full deploy and a version upload that does not shift traffic. --- .github/workflows/deploy-website.yml | 72 +++++++++++++++++++++++++++ .github/workflows/package-release.yml | 28 ++++++++--- README.md | 3 +- README.zh-CN.md | 3 +- 4 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/deploy-website.yml diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 0000000..564533a --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,72 @@ +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 + +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..d4ec5cb 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -1,10 +1,13 @@ 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 permissions: contents: write @@ -24,11 +27,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 +64,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 diff --git a/README.md b/README.md index 67acec9..b2ff8c5 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 +- 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..193e2ac 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 会基于该分支创建 +- 仅部署官网:`.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 二进制已内置前端静态资源 From 984fbd634dbb5abdf6554055ea72965ce70bbb9e Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:27:40 +0800 Subject: [PATCH 3/8] ci: check every subproject on pull requests Nothing ran on pull requests before. Add six jobs covering console, the Go and Rust workers, web and website, gated by dorny/paths-filter so a frontend change does not trigger a Rust build. Changes under api/ fan out to every backend: the Go modules pull it in through a replace directive and worker-boxlite compiles api/proto in build.rs. The ci-ok job aggregates results and treats skipped jobs as success, so it can be the single required check in branch protection. web gains format:check, since format writes files in place and cannot be used as a gate. --- .github/workflows/ci.yml | 281 +++++++++++++++++++++++++++++++++++++++ web/package.json | 3 +- 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1b00c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,281 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: 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 + + - 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/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", From 7a8a0d39aede0bd38b5083302fd1e6d623169e72 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:28:48 +0800 Subject: [PATCH 4/8] chore(scripts): drop the session concurrency e2e harness --- scripts/e2e-session-concurrency.sh | 276 ----------------------- worker/worker-boxlite/README/overview.md | 4 - worker/worker-docker/README/overview.md | 3 - 3 files changed, 283 deletions(-) delete mode 100755 scripts/e2e-session-concurrency.sh 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/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. From c7ed65137a0f41152ccdcf7734f4bb23ecbf7cd3 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:34:15 +0800 Subject: [PATCH 5/8] ci: install protoc for the worker-boxlite job The boxlite-shared build script shells out to protoc; our own build.rs uses protoc-bin-vendored, so this only shows up on a machine without it. --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1b00c3..1da5b8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,14 @@ jobs: - 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: From 941e40ac7612ea5252c72cc8c69b0b62b4160a83 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 09:46:42 +0800 Subject: [PATCH 6/8] fix: address review feedback on the dev tooling PR - package-release: pass target_commitish so a tag created from a non-default branch points at the built commit instead of the repository default branch - package-release: add a latest input, since a manual dispatch makes it easy to patch an older line, which must not steal the Latest label - package-release/deploy-website: share one concurrency group so the two deploy paths cannot race for production traffic - ci: grant pull-requests: read for paths-filter's API fallback - dev.sh: derive console ports from dev.env instead of hardcoding them, so status and port reclamation follow a configured listen address - dev.sh: parse credentials from both json and text log formats; only cache a line the fields can actually be read from - dev.sh: run window commands through bash, since tmux would otherwise use the developer's default-shell --- .github/workflows/ci.yml | 2 + .github/workflows/deploy-website.yml | 2 + .github/workflows/package-release.yml | 22 ++++++- README.md | 2 +- README.zh-CN.md | 2 +- scripts/dev.sh | 89 +++++++++++++++++++++------ 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1da5b8a..21f9786 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,8 @@ on: 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 }} diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 564533a..bd6c33e 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -16,6 +16,8 @@ on: 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 diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index d4ec5cb..ba8a8fe 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -8,6 +8,11 @@ on: 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 @@ -85,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' }} @@ -745,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 @@ -799,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/README.md b/README.md index b2ff8c5..a082d6b 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ 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` — 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 +- 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`) diff --git a/README.zh-CN.md b/README.zh-CN.md index 193e2ac..d333cca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -232,7 +232,7 @@ yarn --cwd web dev ## 发布与镜像 -- GitHub 工作流:`.github/workflows/package-release.yml` —— 在 Actions 页面手动触发,选择分支并填写版本号(如 `0.7.2`),tag 会基于该分支创建 +- 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`) diff --git a/scripts/dev.sh b/scripts/dev.sh index 4e08f77..c8206ba 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -32,10 +32,35 @@ svc_cmd() { 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) echo 8089 ;; + console) configured_port CONSOLE_HTTP_ADDR 8089 ;; web) echo 5178 ;; website) echo 5173 ;; *) echo "" ;; @@ -45,15 +70,21 @@ svc_port() { # 需要一并回收的附加端口(console 还监听 gRPC) svc_extra_ports() { case "$1" in - console) echo 50051 ;; + 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() { - echo "set -a; [ -f '$ENV_FILE' ] && . '$ENV_FILE'; set +a; $(svc_cmd "$1") 2>&1 | tee '$LOG_DIR/$1.log'" + 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() { @@ -328,17 +359,26 @@ cmd_logs() { tail -n 200 "$log" } -# 从扁平 JSON 日志行里取一个字符串字段 -json_field() { - printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" +# 从一行日志里取字段,兼容 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="$(json_field "$line" username)" - password="$(json_field "$line" password)" - api_key_name="$(json_field "$line" api_key_name)" - api_key="$(json_field "$line" 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" @@ -353,21 +393,32 @@ print_creds() { # console 只在首次初始化时把管理员密码打进日志,而 start 每次会清空日志, # 因此首次抓到后固化到 scripts/.dev/console-creds.json 长期保留。 cmd_creds() { + local line="" if [ -f "$CREDS_FILE" ]; then - print_creds "$(cat "$CREDS_FILE")" - return + 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 - local log="$LOG_DIR/console.log" line - if [ -f "$log" ]; then - line="$(grep -F 'console admin account initialized' "$log" | tail -n 1 || true)" - if [ -n "$line" ]; then - mkdir -p "$LOG_DIR" - printf '%s\n' "$line" >"$CREDS_FILE" - chmod 600 "$CREDS_FILE" + 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 < Date: Wed, 29 Jul 2026 10:12:35 +0800 Subject: [PATCH 7/8] fix(web): keep the outgoing label on the nav highlight while it slides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both nav-active snapshots were pinned opaque, and the new one paints on top, so the highlight showed the incoming label from the very first frame — while the box still sat on the item being left. That item read as blinking into a black box carrying another section's name. Fade the incoming label in over the outgoing one instead. The outgoing snapshot stays opaque underneath, which also keeps the black fill solid for the whole slide. --- web/src/style/main.css | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/web/src/style/main.css b/web/src/style/main.css index 93051ea..a725b78 100644 --- a/web/src/style/main.css +++ b/web/src/style/main.css @@ -566,8 +566,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 +575,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; From ffd7d8bfeaad4b54053e7b730b0916eccc987698 Mon Sep 17 00:00:00 2001 From: Coolfan Date: Wed, 29 Jul 2026 10:26:26 +0800 Subject: [PATCH 8/8] fix(web): freeze sidebar tweens while a view transition runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nav item animates its own background over 200ms when it stops being active, and the new snapshot is taken about 30ms in — with the tween barely started, so the item being left is captured still dark. Both sidebar snapshots are static for the transition's whole 320ms, so that dark background sat there until the live DOM took over and dropped it in a single frame: the black flash reported on the item being left. Freeze transitions inside the sidebar for the duration, so the item leaves the active state at once and the highlight hand-off is left to the transition. --- web/src/composables/useViewTransitions.ts | 15 ++++++++++++++- web/src/style/main.css | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 a725b78..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;