From 3f46f90f7833bd29d23c6d2738523e5ec8372fa6 Mon Sep 17 00:00:00 2001 From: Floatingdream3 <185324150+Floatingdream3@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:44:36 +0800 Subject: [PATCH 1/2] feat(windows): add startup and keepalive Add silent Windows sign-in startup, identity-aware HTTP health checks, bounded crash recovery, per-project duplicate protection, configurable ports, rotating logs, and authenticated safe shutdown. Keep manual bridge and static hosting commands available. Include configuration documentation and 24 offline tests using synthetic data. --- .github/workflows/keepalive-tests.yml | 25 + .gitignore | 1 + README.md | 89 +++- Startup/grad-workbench.vbs.in | 7 + Startup/install.cmd | 3 + Startup/uninstall.cmd | 3 + app.js | 17 +- bridge-server.js | 29 +- index.html | 1 + keepalive.example.json | 12 + keepalive.py | 659 ++++++++++++++++++++++++++ tests/frontend-config.js | 39 ++ tests/test_keepalive.py | 525 ++++++++++++++++++++ workbench-config.js | 3 + 14 files changed, 1401 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/keepalive-tests.yml create mode 100644 Startup/grad-workbench.vbs.in create mode 100644 Startup/install.cmd create mode 100644 Startup/uninstall.cmd create mode 100644 keepalive.example.json create mode 100644 keepalive.py create mode 100644 tests/frontend-config.js create mode 100644 tests/test_keepalive.py create mode 100644 workbench-config.js diff --git a/.github/workflows/keepalive-tests.yml b/.github/workflows/keepalive-tests.yml new file mode 100644 index 0000000..efbb6fa --- /dev/null +++ b/.github/workflows/keepalive-tests.yml @@ -0,0 +1,25 @@ +name: Offline keepalive tests + +on: + push: + pull_request: + +jobs: + test: + strategy: + matrix: + os: [windows-latest, ubuntu-latest] + python: ['3.10', '3.12'] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - uses: actions/setup-node@v4 + with: + node-version: '18' + - run: python -m unittest discover -s tests -v + - run: node --check bridge-server.js + - run: node --check app.js + - run: node --check workbench-config.js diff --git a/.gitignore b/.gitignore index ffba484..81e46cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ data/ *.log papers/ +keepalive.json # 依赖 node_modules/ diff --git a/README.md b/README.md index 9dd24fa..cdd258f 100644 --- a/README.md +++ b/README.md @@ -53,11 +53,92 @@ python -m http.server 8899 # 或 npx serve -p 8899 # http://127.0.0.1:8899 ``` -> 💡 附带 `keepalive.py` 保活脚本:每 30 秒自动检测两个端口,服务挂了自动重启。 +> 💡 附带 `keepalive.py` 保活脚本:默认每 30 秒检查服务的 HTTP 响应,异常退出后自动重启。手动运行上述命令的方式保持可用。 ### 方式三:开机自启(推荐重度用户) -Windows 下把 `Startup` 目录里的 VBS 指向 `keepalive.py` 即可实现静默自启。 +需要 **Windows、Python 3.10+(包含 `py` 启动器和 `pythonw.exe`)以及 Node.js 18+**。无需安装 Python 包、npm 包或管理员权限。在项目根目录打开 PowerShell: + +```powershell +py -3 --version +node --version + +# 先在前台验证:启动桥服务和静态服务器,默认地址如下 +py -3 keepalive.py run +# 浏览器打开 http://127.0.0.1:8899 +# 在这个窗口按 Ctrl+C,安全停止保活程序及它启动的服务 + +# 安装当前用户的登录自启(安装本身不会启动服务) +py -3 keepalive.py install-startup +``` + +也可运行 `Startup\install.cmd`。安装器读取 Windows 的当前用户 Startup 文件夹,生成 `GradWorkbench-<项目标识>.vbs`,写入本次使用的 `pythonw.exe`、项目和配置文件的**绝对路径**。VBS 使用隐藏窗口运行,支持中文及带空格的路径。仓库中的 `Startup/grad-workbench.vbs.in` 是模板,请通过安装器生成,勿直接复制。重复安装覆盖同一项目的同名入口。 + +这是**用户登录后**启动;不是登录前运行的 Windows 服务。安装后可立即运行安装器输出的 VBS 文件测试,或重新登录 Windows。重复点击 VBS、重复执行 `run`,或使用不同配置再次启动同一项目,都只保留一个保活实例。可在另一个终端查看和停止: + +```powershell +py -3 keepalive.py status # 显示服务状态、端口、进程及启动次数 +py -3 keepalive.py stop # 等待安全退出;默认最多等 30 秒 +py -3 keepalive.py stop --timeout 60 + +# 取消下次登录启动;也可运行 Startup\uninstall.cmd +py -3 keepalive.py uninstall-startup +# 卸载启动入口不会停止当前会话;需要时再执行 stop +``` + +#### 配置端口与运行参数 + +```powershell +Copy-Item keepalive.example.json keepalive.json +notepad keepalive.json +py -3 keepalive.py run + +# 也支持项目目录之外的配置文件(相对路径以执行命令时的目录为准) +py -3 keepalive.py run --config "D:\我的配置\workbench.json" +py -3 keepalive.py install-startup --config "D:\我的配置\workbench.json" +``` + +未指定 `--config` 时,读取脚本所在目录的 `keepalive.json`;文件不存在则使用默认值。配置修改后执行 `stop`,再运行 VBS 或 `run`。`stop`、`status` 按项目定位实例,无需重复填写配置文件。 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `bridge_port` | `5177` | 桥服务端口,1–65535 | +| `static_port` | `8899` | 网页端口,必须与桥端口不同 | +| `check_interval` | `30` | HTTP 健康检查间隔,秒 | +| `request_timeout` | `2` | 单次本地 HTTP 请求的超时,秒 | +| `startup_timeout` | `15` | 新进程的启动宽限时间,秒 | +| `failure_threshold` | `3` | 宽限结束后,连续检查失败几次才重启仍存活的子进程 | +| `restart_delay` | `1` | 故障后的首次重试等待,秒 | +| `max_restart_delay` | `60` | 指数退避的上限,秒;持续健康达到启动宽限时间后重置 | +| `shutdown_timeout` | `5` | 子进程优雅退出或终止的等待时间,秒 | +| `node` | `"node"` | Node 可执行文件名或绝对路径,如 `"C:/Program Files/nodejs/node.exe"` | + +未知字段、无效数字和相同端口会报错,避免静默使用错误配置。新启动的桥通过 `WORKBENCH_BRIDGE_PORT` 获取端口。保活模式下,静态服务自动提供 `workbench-config.js`,网页的桥请求(包括数据恢复)使用配置后的端口,无需修改 `app.js`。更换网页端口后,直接访问 `http://127.0.0.1:`;双击本地 HTML 的默认地址仍是 8899。 + +手动运行 `node bridge-server.js`、`python -m http.server 8899` 或 `npx serve -p 8899` 仍然可用。手动改桥端口时可先设置 `$env:WORKBENCH_BRIDGE_PORT = '5178'`,并修改 `workbench-config.js` 中的 `bridgePort`;自动保活会自行提供配置。浏览器的 localStorage 按网页来源区分,**更改网页端口前先导出备份**,新地址不会自动继承旧地址的数据。 + +#### 健康检查、恢复与退出规则 + +- 桥检查 `/health` 的服务标识、项目数据目录;静态服务检查 `/_workbench/health` 及 `index.html` 的实际内容。自有子进程还校验启动标识和进程号,避免把其他占用端口的服务认作自己的服务。 +- 子进程异常退出在下一轮检查发现,按退避时间重启;HTTP 持续不健康时,先经过启动宽限和连续失败阈值,再重启。默认检查间隔为 30 秒,因此故障发现与恢复不是即时的。 +- 已经手动运行、身份匹配且健康的服务可继续使用,状态为 `external`。普通手动静态服务器需要能通过 `/index.html` 提供本项目页面,且桥仍使用默认 5177;自定义桥端口请让保活程序启动静态服务。外部服务退出后保活可启动替代服务;仍占着端口但不健康的外部服务不会被强制终止。 +- 无关程序占用端口时,状态为 `conflict`,保留该程序并记录日志,按退避时间继续重试;释放端口或修改配置后可恢复。不会按端口、进程名或旧 PID 批量杀进程。 +- `stop` 和 Ctrl+C 会先停止保活循环,再尝试通过本地 HTTP 让自有子进程退出;超时后仅终止本实例持有进程句柄的子进程。手动启动的外部服务保留。若清理失败或未能确认退出结果,停止命令返回错误并提示查日志。网页中的桥操作在保活模式下显示为重启桥;**持续关闭请使用 `keepalive.py stop`**。 +- 强制结束保活程序不属于安全退出:子进程可能仍运行。再次运行会通过系统锁和健康检查识别现状,避免重复启动;这些遗留服务按外部服务处理。正常停止请使用上面的停止命令。 + +#### 日志与排查 + +运行状态、带认证令牌的控制信息和日志位于 `%LOCALAPPDATA%\grad-workbench\<项目标识>\`,不放在静态网页目录中。`run` 会输出实际目录。`keepalive.log` 包含健康状态变化、启动失败、退出码、退避与子进程输出;单文件最大约 2 MiB,保留 3 个历史文件。控制端口仅监听 `127.0.0.1`,停止和查询必须带每次启动生成的令牌;停止命令不会对磁盘中的旧 PID 发送信号。系统文件锁在异常退出后自动释放,保留的 `instance.lock` 文件无需手动删除。 + +安装后未启动时,先在终端运行 `py -3 keepalive.py run` 查看错误。常见原因是 Node 未进入登录后的 PATH(在配置中填绝对路径)、Python/项目/配置目录被移动,或组织策略禁用了 Windows Script Host。移动项目或更换 Python 后,先从旧目录卸载启动入口,再在新目录安装。Startup 自启不提供保活程序自身崩溃后的守护;重新登录或重新运行 VBS 可启动它。 + +#### 离线自动化测试 + +```powershell +py -3 -m unittest discover -s tests -v +``` + +只需要本机 Python 3.10+、Node.js 18+,无需安装第三方包、登录账号、联网或准备用户数据。测试将源码复制到临时项目,使用合成网页、随机本地端口、独立状态目录和临时 Startup 目录。覆盖正常服务、真实 Node 桥、异常退出恢复、HTTP 不健康、端口冲突及释放后的恢复、重复启动、外部服务保护、带认证的安全停止,以及 Windows VBS 安装/启动/卸载。Windows 专用项在其他系统上自动跳过;测试不会修改真实用户 Startup 文件夹或仓库中的 `data/`。 ## 🎨 定制属于你自己的版本 @@ -76,6 +157,10 @@ grad-workbench/ ├── app.js # 全部逻辑 ├── bridge-server.js # 可选:本地桥服务(Node) ├── keepalive.py # 可选:服务保活脚本 +├── keepalive.example.json # 保活配置示例(本地 keepalive.json 不入库) +├── workbench-config.js # 手动运行时的网页端口默认值 +├── Startup/ # VBS 模板及自启安装/卸载入口 +├── tests/ # 无第三方依赖的离线自动化测试 ├── agent_runner.py # 可选:AI Agent 运行器 ├── assets/ # 图标资源 └── wordbooks/ # 英语词库(JSON) diff --git a/Startup/grad-workbench.vbs.in b/Startup/grad-workbench.vbs.in new file mode 100644 index 0000000..9187463 --- /dev/null +++ b/Startup/grad-workbench.vbs.in @@ -0,0 +1,7 @@ +' Template: install with `py -3 keepalive.py install-startup`. +' Paths and command are filled in by the installer; do not copy this template directly. +Option Explicit +Dim shell +Set shell = CreateObject("WScript.Shell") +shell.CurrentDirectory = @@WORKDIR@@ +shell.Run @@COMMAND@@, 0, False diff --git a/Startup/install.cmd b/Startup/install.cmd new file mode 100644 index 0000000..49ad7b8 --- /dev/null +++ b/Startup/install.cmd @@ -0,0 +1,3 @@ +@echo off +py -3 "%~dp0..\keepalive.py" install-startup %* +exit /b %errorlevel% diff --git a/Startup/uninstall.cmd b/Startup/uninstall.cmd new file mode 100644 index 0000000..812f35c --- /dev/null +++ b/Startup/uninstall.cmd @@ -0,0 +1,3 @@ +@echo off +py -3 "%~dp0..\keepalive.py" uninstall-startup %* +exit /b %errorlevel% diff --git a/app.js b/app.js index 83b2e5a..f766b7c 100644 --- a/app.js +++ b/app.js @@ -36,7 +36,9 @@ function saveJSON(key, val) { scheduleSync(); } /* ============ Agent 桥(本地服务 127.0.0.1:5177,自动同步到 data/sync.json) ============ */ -const BRIDGE = 'http://127.0.0.1:5177'; +const configuredBridgePort = window.WORKBENCH_CONFIG && window.WORKBENCH_CONFIG.bridgePort; +const BRIDGE = 'http://127.0.0.1:' + (Number.isInteger(configuredBridgePort) && configuredBridgePort >= 1 && configuredBridgePort <= 65535 ? configuredBridgePort : 5177); +const BRIDGE_SUPERVISED = !!(window.WORKBENCH_CONFIG && window.WORKBENCH_CONFIG.supervised); let bridgeOnline = false; let bridgeSyncTimer = null; function bridgeCheck() { @@ -76,16 +78,19 @@ function updateBridgeUI() { } const el = document.getElementById('bridgeInd'); if (el) { - el.textContent = bridgeOnline ? '⏹ 关闭桥服务' : '🔌 桥已关闭(点击开启)'; + el.textContent = bridgeOnline ? (BRIDGE_SUPERVISED ? '🔄 重启桥服务' : '⏹ 关闭桥服务') : '🔌 桥已关闭(点击开启)'; el.className = 'bridge-ind ' + (bridgeOnline ? 'on' : 'off'); } } document.getElementById('bridgeInd').addEventListener('click', () => { if (bridgeOnline) { - myConfirm('关闭 Agent 桥服务?\n关闭后网页与 Agent 的数据同步暂停(已同步的数据不丢失)。\n下次需要时:双击桌面「开启工作台桥」,或对 Hermes 说「开桥」。', () => { + const notice = BRIDGE_SUPERVISED + ? '重启 Agent 桥服务?\n保活程序会自动重新启动桥服务。\n如需持续关闭,请按 README 的停止步骤退出保活程序。' + : '关闭 Agent 桥服务?\n关闭后网页与 Agent 的数据同步暂停(已同步的数据不丢失)。\n下次需要时:双击桌面「开启工作台桥」,或对 Hermes 说「开桥」。'; + myConfirm(notice, () => { fetch(BRIDGE + '/control/stop', { method: 'POST', mode: 'cors' }) - .then(() => { bridgeOnline = false; updateBridgeUI(); toast('✅ 桥服务已关闭,内存已释放。'); }) - .catch(() => { bridgeOnline = false; updateBridgeUI(); toast('桥服务已关闭。'); }); + .then(() => { bridgeOnline = false; updateBridgeUI(); toast(BRIDGE_SUPERVISED ? '桥服务正在重启,请稍候。' : '✅ 桥服务已关闭,内存已释放。'); }) + .catch(() => { bridgeOnline = false; updateBridgeUI(); toast(BRIDGE_SUPERVISED ? '桥连接已断开,保活程序将尝试恢复。' : '桥服务已关闭。'); }); }); } else { toast('开启 Agent 桥的两种方式:\n① 双击桌面「开启工作台桥」图标\n② 在 Hermes 对话里说「开桥」,我会帮你启动'); @@ -95,7 +100,7 @@ document.getElementById('bridgeInd').addEventListener('click', () => { (function () { if (location.protocol !== 'http:' && location.protocol !== 'https:') return; try { - fetch('http://127.0.0.1:5177/sync') + fetch(BRIDGE + '/sync') .then(function (r) { return r.json(); }) .then(function (d) { if (!d || typeof d !== 'object') return; diff --git a/bridge-server.js b/bridge-server.js index 2607ab0..4803192 100644 --- a/bridge-server.js +++ b/bridge-server.js @@ -1,6 +1,6 @@ // ============ 工作台 · Agent 桥服务 ============ // 网页(localStorage) <-> 本机文件 <-> Hermes agent -// 仅监听 127.0.0.1:5177,只允许读写本目录 data/ 下的文件 +// 仅监听 127.0.0.1(默认 5177,可通过 WORKBENCH_BRIDGE_PORT 配置) // 控制:POST /control/stop 优雅退出(网页端「关闭」按钮调用) const http = require('http'); const fs = require('fs'); @@ -9,7 +9,12 @@ const { execFile } = require('child_process'); const DATA_DIR = path.join(__dirname, 'data'); const OUT_DIR = path.join(DATA_DIR, 'agent_out'); -const PORT = 5177; +const portSetting = process.env.WORKBENCH_BRIDGE_PORT || '5177'; +if (!/^[1-9]\d*$/.test(portSetting) || Number(portSetting) > 65535) { + console.error('[bridge] WORKBENCH_BRIDGE_PORT must be an integer from 1 to 65535'); + process.exit(2); +} +const PORT = Number(portSetting); // opencode 官网同步(用量数据来自 SSR 页面) const OC_WS = 'wrk_01KYYZWQJJ6T3H5AWJGRE638TN'; const OC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0'; @@ -251,11 +256,13 @@ const server = http.createServer((req, res) => { if (req.method === 'POST' && parts[0] === 'control' && parts[1] === 'stop') { send(res, 200, { ok: true, message: 'stopping' }); - setTimeout(() => { try { server.close(); } catch (e) {} process.exit(0); }, 300); + setTimeout(shutdown, 100); return; } if (req.method === 'GET' && parts[0] === 'health') { - return send(res, 200, { ok: true, dir: DATA_DIR, out: OUT_DIR }); + return send(res, 200, { ok: true, dir: DATA_DIR, out: OUT_DIR, + service: 'grad-workbench-bridge', pid: process.pid, + instance: process.env.WORKBENCH_INSTANCE_ID || null }); } if (req.method === 'GET' && parts[0] === 'list' && parts[1]) { if (parts[1] !== 'agent_out') return send(res, 400, { error: 'only agent_out' }); @@ -882,6 +889,20 @@ const server = http.createServer((req, res) => { send(res, 404, { error: 'not found' }); }); +let shuttingDown = false; +function shutdown() { + if (shuttingDown) return; + shuttingDown = true; + server.close(() => process.exit(0)); + if (server.closeIdleConnections) server.closeIdleConnections(); + setTimeout(() => process.exit(0), 5000).unref(); +} +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); +server.on('error', error => { + console.error('[bridge] listen failed:', error.message); + process.exit(1); +}); server.listen(PORT, '127.0.0.1', () => { console.log('[bridge] listening on http://127.0.0.1:' + PORT); }); diff --git a/index.html b/index.html index 51640b7..2e00990 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ +