From f8787bd3300de22a409b3173d3373bca365b555b Mon Sep 17 00:00:00 2001 From: john Date: Mon, 29 Jun 2026 07:01:26 +0800 Subject: [PATCH] chore: add 105 runtime and openhands ops tooling --- .../g2.tkmind.cn.redirect.nginx.conf | 3 +- deploy/plaza-105/m.tkmind.cn.nginx.conf | 7 +- .../plaza-105/wechat.m.tkmind.cn.nginx.conf | 3 +- docker-compose.openhands-colima.yml | 58 ++++ docker/openhands-colima/002.py | 88 ++++++ docker/openhands-colima/010.py | 34 +++ docker/openhands-colima/Dockerfile | 6 + docs/105-server-operations.md | 58 ++++ docs/local-dev.md | 22 ++ docs/openhands-install.md | 96 ++++++- docs/release-deploy.md | 33 +++ llm-providers.mjs | 27 +- package.json | 2 + scripts/build-portal-runtime.mjs | 27 +- scripts/dev-core-daemon.mjs | 223 +++++++++++++++ scripts/executor-env.mjs | 74 +++++ scripts/install-memind-dev-launchagent.sh | 159 +++++++++++ scripts/openhands-colima.sh | 76 ++++++ scripts/openhands-goose-wrapper.sh | 22 ++ scripts/release-portal-runtime-105.sh | 254 ++++++++++++++++++ scripts/release-portal-runtime-prod.sh | 37 +-- scripts/run-memind-portal-prod.sh | 4 + 22 files changed, 1263 insertions(+), 50 deletions(-) create mode 100644 docker-compose.openhands-colima.yml create mode 100644 docker/openhands-colima/002.py create mode 100644 docker/openhands-colima/010.py create mode 100644 docker/openhands-colima/Dockerfile create mode 100644 scripts/dev-core-daemon.mjs create mode 100755 scripts/executor-env.mjs create mode 100755 scripts/install-memind-dev-launchagent.sh create mode 100755 scripts/openhands-colima.sh create mode 100755 scripts/openhands-goose-wrapper.sh create mode 100644 scripts/release-portal-runtime-105.sh diff --git a/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf b/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf index d7741fe..fc24485 100644 --- a/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf +++ b/deploy/plaza-105/g2.tkmind.cn.redirect.nginx.conf @@ -1,5 +1,6 @@ # g2.tkmind.cn → 301 跳转到 m.tkmind.cn(保留微信 webhook 直至后台改完服务器 URL) # 安装:/etc/nginx/conf.d/g2.tkmind.cn.conf +# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081 server { listen 80; @@ -20,7 +21,7 @@ server { ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location = /webhooks/wechat-mp/messages { - proxy_pass http://127.0.0.1:19081; + proxy_pass http://58.38.22.103:8081; proxy_http_version 1.1; proxy_set_header Host g2.tkmind.cn; proxy_set_header X-Real-IP $remote_addr; diff --git a/deploy/plaza-105/m.tkmind.cn.nginx.conf b/deploy/plaza-105/m.tkmind.cn.nginx.conf index efaf5d3..324301b 100644 --- a/deploy/plaza-105/m.tkmind.cn.nginx.conf +++ b/deploy/plaza-105/m.tkmind.cn.nginx.conf @@ -1,6 +1,7 @@ -# m.tkmind.cn → 103 Memind Portal (:8081) +# m.tkmind.cn → 105 nginx 直连 103 Memind Portal (58.38.22.103:8081) # 安装:/etc/nginx/conf.d/m.tkmind.cn.conf # 替换旧 ai_mind 配置,作为 Memind H5 正式公网入口 +# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081 server { listen 80; @@ -23,7 +24,7 @@ server { client_max_body_size 100M; location = /webhooks/wechat-mp/messages { - proxy_pass http://127.0.0.1:19081; + proxy_pass http://58.38.22.103:8081; proxy_http_version 1.1; proxy_set_header Host m.tkmind.cn; proxy_set_header X-Real-IP $remote_addr; @@ -40,7 +41,7 @@ server { } location / { - proxy_pass http://127.0.0.1:19081; + proxy_pass http://58.38.22.103:8081; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf b/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf index baee759..803b7a6 100644 --- a/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf +++ b/deploy/plaza-105/wechat.m.tkmind.cn.nginx.conf @@ -1,5 +1,6 @@ # wechat.m.tkmind.cn → 微信服务号消息推送专用域名 # 安装:/etc/nginx/conf.d/wechat.m.tkmind.cn.conf +# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081 server { listen 80; @@ -20,7 +21,7 @@ server { ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; location = /webhooks/wechat-mp/messages { - proxy_pass http://127.0.0.1:19081; + proxy_pass http://58.38.22.103:8081; proxy_http_version 1.1; proxy_set_header Host m.tkmind.cn; proxy_set_header X-Real-IP $remote_addr; diff --git a/docker-compose.openhands-colima.yml b/docker-compose.openhands-colima.yml new file mode 100644 index 0000000..c8b99a7 --- /dev/null +++ b/docker-compose.openhands-colima.yml @@ -0,0 +1,58 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: "${OPENHANDS_DB_NAME:-openhands}" + POSTGRES_USER: "${OPENHANDS_DB_USER:-openhands}" + POSTGRES_PASSWORD: "${OPENHANDS_DB_PASS:-openhands_dev_password}" + volumes: + - openhands_pg_data:/var/lib/postgresql/data + healthcheck: + test: + - CMD-SHELL + - pg_isready -U "${OPENHANDS_DB_USER:-openhands}" -d "${OPENHANDS_DB_NAME:-openhands}" + interval: 5s + timeout: 5s + retries: 20 + + openhands: + build: + context: ./docker/openhands-colima + dockerfile: Dockerfile + image: memind/openhands-colima:local + restart: unless-stopped + stdin_open: true + tty: true + depends_on: + postgres: + condition: service_healthy + entrypoint: + - /bin/bash + - -lc + - > + mkdir -p /home/enduser/.openhands && + chown -R ${OPENHANDS_SANDBOX_UID:-501}:0 /home/enduser/.openhands && + exec /app/entrypoint.sh /app/.venv/bin/uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000 + ports: + - "${OPENHANDS_PORT:-3000}:3000" + environment: + LOG_ALL_EVENTS: "${OPENHANDS_LOG_ALL_EVENTS:-true}" + SANDBOX_USER_ID: "${OPENHANDS_SANDBOX_UID:-501}" + SANDBOX_VOLUMES: "${OPENHANDS_WORKSPACE:-/Users/john/Project/Memind}:/workspace:rw" + OH_PERSISTENCE_DIR: "/home/enduser/.openhands" + DB_HOST: "postgres" + DB_PORT: "5432" + DB_NAME: "${OPENHANDS_DB_NAME:-openhands}" + DB_USER: "${OPENHANDS_DB_USER:-openhands}" + DB_PASS: "${OPENHANDS_DB_PASS:-openhands_dev_password}" + volumes: + - "${OPENHANDS_DOCKER_SOCK:-/Users/john/.colima/default/docker.sock}:/var/run/docker.sock" + - openhands_state:/home/enduser/.openhands + - "${OPENHANDS_WORKSPACE:-/Users/john/Project/Memind}:/workspace:rw" + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + openhands_state: + openhands_pg_data: diff --git a/docker/openhands-colima/002.py b/docker/openhands-colima/002.py new file mode 100644 index 0000000..2512d09 --- /dev/null +++ b/docker/openhands-colima/002.py @@ -0,0 +1,88 @@ +"""Sync DB with Models + +Revision ID: 002 +Revises: 001 +Create Date: 2025-10-05 11:28:41.772294 + +""" + +from enum import Enum +from typing import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: str | None = '001' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class EventCallbackStatus(Enum): + ACTIVE = 'ACTIVE' + DISABLED = 'DISABLED' + COMPLETED = 'COMPLETED' + ERROR = 'ERROR' + + +def upgrade() -> None: + """Upgrade schema.""" + bind = op.get_bind() + event_callback_status = sa.Enum( + EventCallbackStatus, + name='eventcallbackstatus', + ) + event_callback_status.create(bind, checkfirst=True) + + op.add_column( + 'event_callback', + sa.Column( + 'status', + event_callback_status, + nullable=False, + server_default='ACTIVE', + ), + ) + op.add_column( + 'event_callback', + sa.Column( + 'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now() + ), + ) + op.drop_index('ix_event_callback_result_event_id') + op.drop_column('event_callback_result', 'event_id') + op.add_column( + 'event_callback_result', sa.Column('event_id', sa.String, nullable=True) + ) + op.create_index( + op.f('ix_event_callback_result_event_id'), + 'event_callback_result', + ['event_id'], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + bind = op.get_bind() + event_callback_status = sa.Enum( + EventCallbackStatus, + name='eventcallbackstatus', + ) + + op.drop_column('event_callback', 'status') + op.drop_column('event_callback', 'updated_at') + op.drop_index('ix_event_callback_result_event_id') + op.drop_column('event_callback_result', 'event_id') + op.add_column( + 'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True) + ) + op.create_index( + op.f('ix_event_callback_result_event_id'), + 'event_callback_result', + ['event_id'], + unique=False, + ) + + event_callback_status.drop(bind, checkfirst=True) diff --git a/docker/openhands-colima/010.py b/docker/openhands-colima/010.py new file mode 100644 index 0000000..055704c --- /dev/null +++ b/docker/openhands-colima/010.py @@ -0,0 +1,34 @@ +"""Add composite index on event_callback for execute_callbacks query + +Revision ID: 010 +Revises: 009 +Create Date: 2026-06-03 +""" + +from typing import Sequence + +from alembic import op + +revision: str = '010' +down_revision: str | None = '009' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Local PG validation uses an empty dev database, so a regular index keeps + # startup reliable without depending on autocommit behavior. + op.create_index( + 'ix_event_callback_conversation_id_status_event_kind', + 'event_callback', + ['conversation_id', 'status', 'event_kind'], + if_not_exists=True, + ) + + +def downgrade() -> None: + op.drop_index( + 'ix_event_callback_conversation_id_status_event_kind', + table_name='event_callback', + if_exists=True, + ) diff --git a/docker/openhands-colima/Dockerfile b/docker/openhands-colima/Dockerfile new file mode 100644 index 0000000..29d47af --- /dev/null +++ b/docker/openhands-colima/Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/openhands/openhands:latest + +RUN /app/.venv/bin/pip install --no-cache-dir asyncpg + +COPY 002.py /app/openhands/app_server/app_lifespan/alembic/versions/002.py +COPY 010.py /app/openhands/app_server/app_lifespan/alembic/versions/010.py diff --git a/docs/105-server-operations.md b/docs/105-server-operations.md index 178e977..6e6d43f 100644 --- a/docs/105-server-operations.md +++ b/docs/105-server-operations.md @@ -56,6 +56,64 @@ **不要**使用已停用的 `rsync_to_server.sh` 或直接 rsync 到 `105` 作为常规发布手段(见 `PRODUCTION_RELEASE_RULES.md`)。 +## 105 Portal runtime 发布 + +`105` 上当前 H5 由 `systemd` 服务 `goose-h5` 运行,真实目录是: + +```text +/root/tkmind_go/ui/h5 +``` + +发布 `105` 的 Portal / 微信服务号相关能力时,必须走 **runtime artifact**,不要直接发源码树: + +```bash +cd /Users/john/Project/Memind + +# 仅预演,不切换 live +bash scripts/release-portal-runtime-105.sh --dry-run + +# 正式发布到 105 +bash scripts/release-portal-runtime-105.sh --yes +``` + +### 这条发布链的关键规则 + +1. 本地先把 `server.mjs` 与 `mindspace-sandbox-mcp.mjs` 打成 runtime bundle,再连同 `dist/`、`public/`、`schema.sql`、启动脚本一起打包。 +2. **不要**把本机 Mac 的 `node_modules` 直接打进包发到 `105`。 + `105` 是 Linux,像 `@node-rs/argon2` 这类原生依赖会因为平台不匹配而启动失败。 +3. `105` 的 runtime 包默认**不带** `node_modules`;切换前会在 `105` 本机执行: + +```bash +npm install --omit=dev --no-package-lock +``` + +确保安装的是 Linux 可用依赖。 +4. 发布时默认保留这些持久项,不覆盖: + - `.env` + - `MindSpace/` + - `data/` + - `users/` + - `logs/` + - `public/plaza-covers/` +5. 发布完成后必须验: + +```bash +ssh root@120.26.184.105 ' + systemctl is-active goose-h5 + curl -sf http://127.0.0.1:8080/api/status +' +``` + +正确结果应为: +- `goose-h5` = `active` +- `/api/status` 返回 `ok` + +### 禁止事项(105 runtime) + +- 禁止把 `node_modules/` 从本机打包后直接发到 `105` +- 禁止在 `105` 直接改 `server.mjs` / `wechat-mp.mjs` / `scripts/wechat-mp-menu.mjs` +- 禁止跳过健康检查就宣称“已发布” + ## 服务号底部菜单(`wechat-mp-menu.mjs`) 菜单名称与链接定义在本地: diff --git a/docs/local-dev.md b/docs/local-dev.md index 91b073d..55115fc 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -22,6 +22,28 @@ pnpm dev pnpm open:local-test # 浏览器打开 H5 ``` +### LaunchAgent 常驻(推荐本机联调) + +终端里跑 `pnpm dev` 会在 Cursor / 终端关闭时被 SIGTERM 停掉。若希望登录后自动拉起、掉线自恢复,可安装 LaunchAgent(与 imgproxy / goosed 同类): + +```bash +pnpm setup:dev-launchagent +# 卸载 +pnpm setup:dev-launchagent:uninstall +``` + +| 项 | 值 | +|----|-----| +| Label | `cn.tkmind.memind-dev` | +| 进程 | portal (8081) + memind_adm (8082) + Ops (3002) + Vite (5173) | +| 日志 | `~/Library/Logs/memind-dev.log` | + +安装后仍可用 `pnpm dev` 做前台调试;两者不要同时占用同一端口。重启服务: + +```bash +launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-dev +``` + 需要旧式全栈联动时,改用: ```bash diff --git a/docs/openhands-install.md b/docs/openhands-install.md index 201c192..63a6284 100644 --- a/docs/openhands-install.md +++ b/docs/openhands-install.md @@ -1,18 +1,22 @@ # OpenHands 安装说明 -> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 `memindadm` 的 Goose 网关策略中心。 +> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。 > > 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。 ## 1. 推荐方案 -如果你的目标是后续和 Goose 做集成,建议先用 **OpenHands CLI + GUI Server** 方式启动: +如果你的目标是后续和 Goose 做集成,要先分清两条链: -- 本地直接运行 -- 可挂载当前仓库目录 -- 便于后续做执行器接入验证 +- `OpenHands Web / GUI`:浏览器访问的服务,适合人工打开页面验证 +- `Goose -> OpenHands`:Goose 直接拉起 `OpenHands CLI` 进程做委托执行,不走 `127.0.0.1:3000` 这个网页入口 -官方文档对 `openhands serve` 的说明是:它会通过 Docker 启动本地 GUI Server,支持挂载当前目录,适合直接对仓库做任务。 +本仓库当前建议: + +- 页面服务用 `Colima + docker compose` +- Goose 委托执行继续用本机 `OpenHands CLI` + +这样边界最清楚,也不会把 Goose 和 OpenHands 强耦合到一个镜像里。 ## 2. 前置条件 @@ -73,6 +77,44 @@ openhands serve --mount-cwd 如果你更偏向容器化,可按 OpenHands 的 Agent Canvas / Docker 文档走容器启动方式。这个方式适合未来把 OpenHands 作为更独立的执行环境来跑。 +### 方式 D:本仓库 Colima compose + +如果本机 Docker 实际跑在 Colima,而不是 Docker Desktop 默认 socket,可以直接使用仓库内置的 compose 文件: + +```bash +cd /Users/john/Project/Memind +bash scripts/openhands-colima.sh pull +bash scripts/openhands-colima.sh build +bash scripts/openhands-colima.sh up +``` + +默认行为: + +- 启动 `postgres:16-alpine` +- 启动本地 overlay 镜像 `memind/openhands-colima:local` +- 挂载 Colima socket:`/Users/john/.colima/default/docker.sock` +- 挂载当前仓库到容器内:`/workspace` +- 对外暴露 GUI 端口:`3000` +- OpenHands 持久化走本地 Postgres,而不是 SQLite + +常用命令: + +```bash +bash scripts/openhands-colima.sh build +bash scripts/openhands-colima.sh status +bash scripts/openhands-colima.sh logs +bash scripts/openhands-colima.sh restart +bash scripts/openhands-colima.sh down +``` + +当前 PG 默认值: + +- `OPENHANDS_DB_NAME=openhands` +- `OPENHANDS_DB_USER=openhands` +- `OPENHANDS_DB_PASS=openhands_dev_password` + +如果需要自定义,可在执行脚本前导出环境变量。 + ### 端口与资源 - GUI Server 默认会占用 `3000` 端口 @@ -133,11 +175,28 @@ openhands serve --mount-cwd 启动成功后,确认以下几点: 1. 浏览器能打开 OpenHands 的界面。 -2. 可以创建一次简单任务。 -3. 如果用了 `--mount-cwd`,任务可以看到当前仓库。 +2. `curl http://127.0.0.1:3000` 返回 `200 OK`。 +3. Postgres 中可以看到 OpenHands 表,例如 `event_callback`、`conversation_metadata`。 4. Docker 不报权限或 socket 错误。 -## 7. 用于后续 Goose 集成时的建议 +## 7. Goose 集成边界 + +当前 Goose 不是调用 `http://127.0.0.1:3000` 这个 GUI 服务,而是直接执行本机 `OpenHands CLI`: + +- `GOOSE_OPENHANDS_BIN=/Users/john/.openhands/bin/studio-openhands` +- `GOOSE_OPENHANDS_RUNNER=host` + +其中: + +- `/Users/john/.openhands/bin/studio-openhands` 目前只是一个 wrapper +- 实际执行的是 `/Users/john/.local/bin/openhands` + +这意味着: + +- `Colima` 里的 OpenHands Web 服务,主要用于人手打开页面和验证 PG 持久化 +- Goose 的 OpenHands 委托执行,主要取决于本机 CLI 是否可用、模型环境变量是否齐全 + +## 8. 用于后续 Goose 集成时的建议 为了后面接入 `memindadm`,建议你先准备好这些信息: @@ -152,7 +211,7 @@ openhands serve --mount-cwd 如果你后面已经决定把 `Goose / Aider / OpenHands` 的模型统一收口到 `memindadm`,那么 OpenHands 这边也不要再单独维护自己的模型配置,统一从后台读取即可。 -## 8. 和现有 Goose 服务的关系 +## 9. 和现有 Goose 服务的关系 这一步是旁路安装,不会改动现有 Goose 服务。 @@ -163,7 +222,22 @@ openhands serve --mount-cwd - OpenHands 先作为独立执行器接入 - 真正切流之前,先做只读审计和联调验证 -## 9. 官方参考 +## 10. 本地验证结论 + +本仓库本机 `Colima` 已验证通过: + +- OpenHands Web 服务可访问 +- OpenHands 数据落本地 Postgres +- 官方镜像直接切 PG 有迁移坑,因此当前 compose 使用的是仓库内的薄 overlay 镜像修复 + +相关文件: + +- `/Users/john/Project/Memind/docker-compose.openhands-colima.yml` +- `/Users/john/Project/Memind/docker/openhands-colima/Dockerfile` +- `/Users/john/Project/Memind/docker/openhands-colima/002.py` +- `/Users/john/Project/Memind/docker/openhands-colima/010.py` + +## 11. 官方参考 - OpenHands 安装文档 - OpenHands GUI Server diff --git a/docs/release-deploy.md b/docs/release-deploy.md index 1016c7d..75a97be 100644 --- a/docs/release-deploy.md +++ b/docs/release-deploy.md @@ -12,6 +12,14 @@ 4. 103 **禁止** `npm install`、`npm run build`、在线改源码后继续运行。 5. 切换后必须通过 Portal 健康检查;失败立即回滚。 +补充: + +- `103 / Studio` runtime 发布入口:`bash scripts/release-portal-runtime-prod.sh` +- `105` runtime 发布入口:`bash scripts/release-portal-runtime-105.sh` +- 两者都必须是 **runtime artifact**,不要直接发源码树 +- `105` 与 `103` 的关键差异是: + `105` 为 Linux 环境,**不要携带本机 Mac 的 `node_modules`**;应在 `105` 切换前本机执行 `npm install --omit=dev --no-package-lock` + ## 唯一入口 ```bash @@ -19,6 +27,13 @@ bash scripts/release-portal-runtime-prod.sh --dry-run bash scripts/release-portal-runtime-prod.sh ``` +如果目标环境是 `105`: + +```bash +bash scripts/release-portal-runtime-105.sh --dry-run +bash scripts/release-portal-runtime-105.sh --yes +``` + ## 产物发布流程 1. 本地 commit 当前改动。 @@ -32,6 +47,24 @@ bash scripts/release-portal-runtime-prod.sh 9. 启动 Portal,检查 `127.0.0.1:8081/api/status` 为 200。 10. 旧源码 live 目录移入 `archives/`,线上不再保留可运行源码树。 +## 105 runtime 发布差异 + +`105` 当前不是 launchd,而是 `systemd` 服务: + +```text +goose-h5.service +WorkingDirectory=/root/tkmind_go/ui/h5 +EnvironmentFile=/root/tkmind_go/ui/h5/.env +ExecStart=/opt/node/bin/node /root/tkmind_go/ui/h5/server.mjs +``` + +所以 `105` 发布时要额外满足: + +1. 保留 `.env` / `MindSpace/` / `data/` / `users/` / `logs/` / `public/plaza-covers/` +2. 远端切换前在新的 runtime 目录执行 Linux 依赖安装 +3. 最终用 `systemctl restart goose-h5` 拉起服务 +4. 健康检查目标是 `http://127.0.0.1:8080/api/status` + ## 禁止事项 - 禁止 `rsync_to_server.sh` diff --git a/llm-providers.mjs b/llm-providers.mjs index 6461a37..3ff25c4 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -374,8 +374,9 @@ function rowToExecutorBinding(row, keyPublic = null) { function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) { const apiKeyValue = includeSecret ? profile.apiKey : '[hidden]'; - const baseUrl = resolveApiBaseUrl(profile.apiUrl); const providerId = profile.providerId; + const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || ''; + const baseUrl = resolveApiBaseUrl(resolvedApiUrl); const common = { TKMIND_EXECUTOR: executor, TKMIND_EXECUTOR_PROVIDER: providerId, @@ -436,7 +437,25 @@ function normalizeLaunchMode(mode) { return normalized === 'serve' ? 'serve' : 'headless'; } +function resolveExecutorCommand(executor) { + const candidates = + executor === 'aider' + ? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, 'aider'] + : executor === 'openhands' + ? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, 'openhands'] + : [executor]; + return candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor; +} + function commandExists(command) { + if (path.isAbsolute(command)) { + try { + fs.accessSync(command, fs.constants.X_OK); + return true; + } catch { + return false; + } + } const probe = spawnSync('sh', ['-lc', `command -v ${JSON.stringify(command).slice(1, -1)}`], { stdio: 'ignore', }); @@ -541,11 +560,12 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { if (runtime.executor === 'aider') { const hasInstruction = Boolean(instruction); + const command = resolveExecutorCommand('aider'); return { ok: true, executor: 'aider', cwd, - command: 'aider', + command, args: [ '--model', runtime.model, @@ -568,6 +588,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { message: 'OpenHands headless 模式需要 instruction', }; } + const command = resolveExecutorCommand('openhands'); const args = mode === 'serve' ? ['serve', '--mount-cwd'] @@ -576,7 +597,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { ok: true, executor: 'openhands', cwd, - command: 'openhands', + command, args, env, notes: [ diff --git a/package.json b/package.json index 87e15a2..3e0c35d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "dev": "node scripts/dev-core.mjs", + "setup:dev-launchagent": "bash scripts/install-memind-dev-launchagent.sh", + "setup:dev-launchagent:uninstall": "bash scripts/install-memind-dev-launchagent.sh --uninstall", "dev:all": "node scripts/dev.mjs", "dev:plaza": "node scripts/dev-plaza.mjs", "start:plaza": "node scripts/start-plaza-prod.mjs", diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index 3f507b0..b4c4ab5 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -14,6 +14,8 @@ const schemaFile = path.join(root, 'schema.sql'); const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild'); const skipBuild = process.argv.includes('--skip-build'); const skipNodeModules = process.argv.includes('--skip-node-modules'); +const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24'; +const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules'; const externalPackages = [ '@node-rs/argon2', @@ -84,7 +86,7 @@ async function bundleServer() { '--bundle', '--platform=node', '--format=esm', - '--target=node24', + `--target=${runtimeNodeTarget}`, '--outfile=.runtime/portal/server.mjs', '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', ]; @@ -104,7 +106,7 @@ async function bundleSandboxMcp() { '--bundle', '--platform=node', '--format=esm', - '--target=node24', + `--target=${runtimeNodeTarget}`, '--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs', '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', ]; @@ -175,10 +177,19 @@ async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModul async function writeMetadata() { const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); + const runtimeDependencies = Object.fromEntries( + externalPackages + .filter((pkg) => packageJson.dependencies?.[pkg]) + .map((pkg) => [pkg, packageJson.dependencies[pkg]]), + ); const runtimePackageJson = { name: `${packageJson.name}-portal-runtime`, private: true, type: 'module', + engines: { + node: '>=22', + }, + dependencies: runtimeDependencies, }; await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`); await writeFile( @@ -203,14 +214,15 @@ async function writeMetadata() { 'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"', 'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"', 'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"', - 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"', - 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"', '', 'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"', 'if [[ ! -x "${NODE_BIN}" ]]; then', ' NODE_BIN="$(command -v node)"', 'fi', '', + 'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"', + 'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"', + '', 'exec "${NODE_BIN}" "${ROOT}/server.mjs"', '', ].join('\n'), @@ -241,6 +253,9 @@ async function writeMetadata() { 'Bundled alongside server.mjs (required for sandbox-fs MCP):', ' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)', '', + `Runtime install mode: ${runtimeInstallMode}`, + `Bundle target: ${runtimeNodeTarget}`, + '', 'Key runtime differences must stay in .env, not in the artifact:', ' DATABASE_URL / MYSQL_*', ' H5_PUBLIC_BASE_URL', @@ -261,7 +276,9 @@ async function main() { await copyRuntimeAssets(); await bundleServer(); await bundleSandboxMcp(); - await copyNodeModules(); + if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) { + await copyNodeModules(); + } await writeMetadata(); await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755); diff --git a/scripts/dev-core-daemon.mjs b/scripts/dev-core-daemon.mjs new file mode 100644 index 0000000..64231f3 --- /dev/null +++ b/scripts/dev-core-daemon.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * LaunchAgent / 后台常驻版 dev 栈:portal + admin + ops + vite。 + * 单个子进程异常退出时会自动重启;启动阶段失败则整体退出,由 launchd KeepAlive 重试。 + */ +import { spawn, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const opsDir = path.join(root, 'ops'); +const nodeBin = process.execPath; +const portalPort = Number(process.env.H5_PORT ?? 8081); +const vitePort = Number(process.env.VITE_PORT ?? 5173); +const plazaPort = Number(process.env.PLAZA_PORT ?? 3001); +const opsPort = Number(process.env.OPS_PORT ?? 3002); +const adminPort = Number(process.env.ADMIN_PORT ?? 8082); +const portalUrl = `http://127.0.0.1:${portalPort}`; +const adminUrl = `http://127.0.0.1:${adminPort}`; +const plazaPublicBase = ( + process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}` +).replace(/\/$/, ''); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '../../.env.local')); +loadEnvFile(path.join(root, '.env')); + +const logFile = + process.env.MEMIND_DEV_LOG ?? path.join(os.homedir(), 'Library/Logs/memind-dev.log'); + +function log(message) { + const line = `[${new Date().toISOString()}] ${message}\n`; + try { + fs.appendFileSync(logFile, line); + } catch { + process.stdout.write(line); + } +} + +function freePort(port) { + try { + execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' }); + } catch { + // port already free + } +} + +let fullyStarted = false; +let stopping = false; +const children = { server: null, admin: null, ops: null, vite: null }; + +function shutdown(code = 0) { + if (stopping) return; + stopping = true; + for (const child of Object.values(children)) { + child?.kill('SIGTERM'); + } + setTimeout(() => process.exit(code), 300); +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +function attachExitHandler(label, child, respawn) { + child.on('exit', (code, signal) => { + if (stopping || signal === 'SIGTERM') return; + if (!code || code === 0) return; + if (!fullyStarted) { + log(`[${label}] 启动阶段退出 code=${code}`); + shutdown(code); + return; + } + log(`[${label}] 异常退出 code=${code},3 秒后重启...`); + setTimeout(() => { + if (stopping) return; + respawn(); + }, 3000); + }); +} + +function spawnPortal() { + const child = spawn(nodeBin, [path.join(root, 'server.mjs')], { + cwd: root, + env: process.env, + stdio: 'inherit', + }); + children.server = child; + attachExitHandler('portal', child, spawnPortal); + return child; +} + +function spawnAdmin() { + const child = spawn(nodeBin, [path.join(root, 'admin-server.mjs')], { + cwd: root, + env: process.env, + stdio: 'inherit', + }); + children.admin = child; + attachExitHandler('admin', child, spawnAdmin); + return child; +} + +function spawnOps() { + const child = spawn('npm', ['run', 'dev'], { + cwd: opsDir, + env: { ...process.env, ...opsEnv }, + stdio: 'inherit', + }); + children.ops = child; + attachExitHandler('ops', child, spawnOps); + return child; +} + +function spawnVite() { + const child = spawn('npx', ['vite'], { + cwd: root, + env: { ...process.env, ...viteEnv }, + stdio: 'inherit', + }); + children.vite = child; + attachExitHandler('vite', child, spawnVite); + return child; +} + +async function waitFor(url, check, label, retries = 60) { + for (let i = 0; i < retries; i += 1) { + try { + if (await check(url)) return; + } catch { + // still starting + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error(`${label} 未在 ${url} 启动`); +} + +const mindSpacePublicBase = ( + process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}` +).replace(/\/$/, ''); + +const viteEnv = { + VITE_PLAZA_BASE: plazaPublicBase, + VITE_MINDSPACE_BASE: mindSpacePublicBase, +}; + +const opsEnv = { + OPS_API_PROXY: portalUrl, + OPS_ADMIN_PROXY: adminUrl, + VITE_PLAZA_BASE: plazaPublicBase, + VITE_MINDSPACE_BASE: mindSpacePublicBase, +}; + +log(`清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${opsPort}...`); +freePort(portalPort); +freePort(adminPort); +freePort(vitePort); +freePort(opsPort); +await new Promise((resolve) => setTimeout(resolve, 500)); + +log('启动 portal (server.mjs)...'); +spawnPortal(); + +try { + await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal'); + log(`Portal 就绪: ${portalUrl}`); + + log('启动 memind_adm (admin-server.mjs)...'); + spawnAdmin(); + await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin'); + log(`memind_adm 就绪: ${adminUrl}`); + + log(`启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`); + spawnOps(); + await waitFor( + `http://127.0.0.1:${opsPort}`, + async (url) => (await fetch(`${url}/ops/`)).ok, + 'Ops', + 40, + ); + log(`Ops 就绪: http://127.0.0.1:${opsPort}/ops/`); + + log(`启动 Vite @ http://127.0.0.1:${vitePort}`); + spawnVite(); + await waitFor( + `http://127.0.0.1:${vitePort}`, + async (url) => { + const [rootRes, mainRes] = await Promise.all([ + fetch(`${url}/`), + fetch(`${url}/src/main.tsx`), + ]); + return rootRes.ok && mainRes.ok; + }, + 'Vite', + ); + log(`Vite 就绪: http://127.0.0.1:${vitePort}`); + + fullyStarted = true; + log(''); + log('本地服务(LaunchAgent 常驻):'); + log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`); + log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`); + log(` API / Portal ${portalUrl}`); + log(` memind_adm ${adminUrl}`); + log(''); + log('Plaza 未包含在此 Agent 内;需要时请单独运行 pnpm dev:plaza'); +} catch (err) { + log(err instanceof Error ? err.message : String(err)); + shutdown(1); +} diff --git a/scripts/executor-env.mjs b/scripts/executor-env.mjs new file mode 100755 index 0000000..0bd2f4c --- /dev/null +++ b/scripts/executor-env.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { createLlmProviderService } from '../llm-providers.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +function parseArgs(argv) { + const out = { + executor: String(argv[0] ?? '').trim(), + purpose: 'default', + shell: false, + }; + for (let i = 1; i < argv.length; i += 1) { + const item = argv[i]; + if (item === '--purpose') out.purpose = String(argv[++i] ?? out.purpose); + else if (item === '--shell') out.shell = true; + } + return out; +} + +loadEnvFile(path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +const parsed = parseArgs(process.argv.slice(2)); +if (!parsed.executor) { + console.error('Usage: node scripts/executor-env.mjs [--purpose default] [--shell]'); + process.exit(1); +} + +const pool = createDbPool(); +try { + const svc = createLlmProviderService(pool, { + apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', + apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', + }); + const runtime = await svc.getExecutorRuntimeConfig(parsed.executor, { + purpose: parsed.purpose, + includeSecret: true, + }); + if (!runtime?.ok) { + console.error(runtime?.message ?? 'executor runtime unavailable'); + process.exit(2); + } + const env = runtime.env ?? {}; + if (parsed.shell) { + for (const [key, value] of Object.entries(env)) { + process.stdout.write(`export ${key}=${shellQuote(value)}\n`); + } + } else { + process.stdout.write(`${JSON.stringify({ executor: parsed.executor, env })}\n`); + } +} finally { + await pool.end().catch(() => {}); +} diff --git a/scripts/install-memind-dev-launchagent.sh b/scripts/install-memind-dev-launchagent.sh new file mode 100755 index 0000000..03236b3 --- /dev/null +++ b/scripts/install-memind-dev-launchagent.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-${ROOT}/.env}" +LABEL="${LABEL:-cn.tkmind.memind-dev}" +PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist" +LOG_DIR="${HOME}/Library/Logs" +LOG_FILE="${LOG_DIR}/memind-dev.log" +DAEMON_SCRIPT="${ROOT}/scripts/dev-core-daemon.mjs" +UID_NUM="$(id -u)" +GUI="gui/${UID_NUM}" +PATH_EXPORT="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin" + +usage() { + cat </dev/null 2>&1; then + launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true + sleep 1 + fi + rm -f "${PLIST}" + echo "已卸载 LaunchAgent: ${LABEL}" +} + +if [[ "${1:-}" == "--uninstall" ]]; then + uninstall + exit 0 +fi + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +if [[ ! -f "${ENV_FILE}" ]]; then + echo "未找到环境文件: ${ENV_FILE}" >&2 + echo "请先 cp .env.example .env 并配置数据库等变量" >&2 + exit 1 +fi + +if [[ ! -f "${DAEMON_SCRIPT}" ]]; then + echo "未找到 daemon 脚本: ${DAEMON_SCRIPT}" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "${ENV_FILE}" +set +a + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node || true)" +fi +if [[ ! -x "${NODE_BIN}" ]]; then + echo "未找到 node,请安装 Node.js 或设置 NODE_BIN" >&2 + exit 1 +fi + +if [[ ! -d "${ROOT}/node_modules" ]]; then + echo "未找到 node_modules,请先在 ${ROOT} 执行 pnpm install" >&2 + exit 1 +fi + +if [[ ! -d "${ROOT}/ops/node_modules" ]]; then + echo "未找到 ops/node_modules,请先在 ${ROOT}/ops 执行 npm install" >&2 + exit 1 +fi + +mkdir -p "${HOME}/Library/LaunchAgents" "${LOG_DIR}" + +cat >"${PLIST}" < + + + + Label + ${LABEL} + ProgramArguments + + ${NODE_BIN} + ${DAEMON_SCRIPT} + + WorkingDirectory + ${ROOT} + EnvironmentVariables + + PATH + ${PATH_EXPORT} + HOME + ${HOME} + + RunAtLoad + + KeepAlive + + ThrottleInterval + 15 + StandardOutPath + ${LOG_FILE} + StandardErrorPath + ${LOG_FILE} + + +EOF + +plutil -lint "${PLIST}" + +if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then + launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true + sleep 1 +fi + +launchctl bootstrap "${GUI}" "${PLIST}" +launchctl enable "${GUI}/${LABEL}" +launchctl kickstart -k "${GUI}/${LABEL}" + +echo "等待 H5 开发栈就绪..." +portal_port="${H5_PORT:-8081}" +vite_port="${VITE_PORT:-5173}" +ready=0 +for _ in $(seq 1 90); do + if curl -sf "http://127.0.0.1:${portal_port}/auth/status" >/dev/null 2>&1 \ + && curl -sf "http://127.0.0.1:${vite_port}/" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done + +echo "" +echo "=== Memind 本地开发栈 (LaunchAgent) ===" +launchctl list | grep "${LABEL}" || true +if [[ "${ready}" -eq 1 ]]; then + echo "Portal: http://127.0.0.1:${portal_port}" + echo "H5 UI: http://127.0.0.1:${vite_port}/?preview=mindspace" +else + echo "服务尚未就绪,请查看日志: tail -f ${LOG_FILE}" +fi +echo "" +echo "LaunchAgent: ${PLIST}" +echo "Log file: ${LOG_FILE}" +echo "" +echo "常用命令:" +echo " launchctl kickstart -k ${GUI}/${LABEL} # 重启" +echo " tail -f ${LOG_FILE} # 看日志" +echo " bash scripts/install-memind-dev-launchagent.sh --uninstall" diff --git a/scripts/openhands-colima.sh b/scripts/openhands-colima.sh new file mode 100755 index 0000000..6b54906 --- /dev/null +++ b/scripts/openhands-colima.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="${ROOT_DIR}/docker-compose.openhands-colima.yml" + +export OPENHANDS_WORKSPACE="${OPENHANDS_WORKSPACE:-${ROOT_DIR}}" +export OPENHANDS_DOCKER_SOCK="${OPENHANDS_DOCKER_SOCK:-/Users/john/.colima/default/docker.sock}" +export OPENHANDS_CONFIG_DIR="${OPENHANDS_CONFIG_DIR:-/Users/john/.openhands}" +export OPENHANDS_PORT="${OPENHANDS_PORT:-3000}" +export OPENHANDS_SANDBOX_UID="${OPENHANDS_SANDBOX_UID:-$(id -u)}" +export OPENHANDS_DB_NAME="${OPENHANDS_DB_NAME:-openhands}" +export OPENHANDS_DB_USER="${OPENHANDS_DB_USER:-openhands}" +export OPENHANDS_DB_PASS="${OPENHANDS_DB_PASS:-openhands_dev_password}" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found in PATH" >&2 + exit 1 +fi + +if [ ! -S "${OPENHANDS_DOCKER_SOCK}" ]; then + echo "docker socket not found: ${OPENHANDS_DOCKER_SOCK}" >&2 + exit 1 +fi + +mkdir -p "${OPENHANDS_CONFIG_DIR}" + +usage() { + cat < + +Environment: + OPENHANDS_WORKSPACE Workspace mounted into /workspace + OPENHANDS_DOCKER_SOCK Docker socket mounted into the service container + OPENHANDS_CONFIG_DIR Legacy host config directory (kept for CLI wrappers) + OPENHANDS_PORT Host port published for the GUI server + OPENHANDS_DB_NAME Postgres database name for OpenHands + OPENHANDS_DB_USER Postgres user for OpenHands + OPENHANDS_DB_PASS Postgres password for OpenHands +EOF +} + +cmd="${1:-status}" +shift || true + +compose() { + docker compose -f "${COMPOSE_FILE}" "$@" +} + +case "${cmd}" in + build) + compose build openhands + ;; + pull) + compose pull postgres + ;; + up) + compose up -d postgres openhands + ;; + down) + compose down + ;; + restart) + compose up -d --force-recreate postgres openhands + ;; + logs) + compose logs -f --tail=200 postgres openhands + ;; + status) + compose ps + ;; + *) + usage + exit 1 + ;; +esac diff --git a/scripts/openhands-goose-wrapper.sh b/scripts/openhands-goose-wrapper.sh new file mode 100755 index 0000000..01731fe --- /dev/null +++ b/scripts/openhands-goose-wrapper.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="/Users/john/Project/Memind" +ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs" +OPENHANDS_BIN_DEFAULT="/Users/john/.local/bin/openhands" +OPENHANDS_BIN="${OPENHANDS_CLI_BIN:-${OPENHANDS_BIN:-${OPENHANDS_BIN_DEFAULT}}}" +NODE_BIN="${NODE_BIN:-$(command -v node || true)}" + +if [[ -z "${NODE_BIN}" ]]; then + echo "node not found in PATH" >&2 + exit 1 +fi + +if [[ ! -x "${OPENHANDS_BIN}" ]]; then + echo "OpenHands CLI not executable: ${OPENHANDS_BIN}" >&2 + exit 1 +fi + +eval "$("${NODE_BIN}" "${ENV_SCRIPT}" openhands --shell)" + +exec "${OPENHANDS_BIN}" "$@" diff --git a/scripts/release-portal-runtime-105.sh b/scripts/release-portal-runtime-105.sh new file mode 100644 index 0000000..bbecbdd --- /dev/null +++ b/scripts/release-portal-runtime-105.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}" +REMOTE_ROOT="${REMOTE_ROOT:-/root/tkmind_go}" +APP_DIR="${APP_DIR:-${REMOTE_ROOT}/ui/h5}" +INCOMING_DIR="${INCOMING_DIR:-${REMOTE_ROOT}/incoming/memind-portal-runtime-105}" +BACKUP_DIR="${BACKUP_DIR:-${REMOTE_ROOT}/backups}" +ARCHIVE_DIR="${ARCHIVE_DIR:-${REMOTE_ROOT}/archives}" +SERVICE_NAME="${SERVICE_NAME:-goose-h5}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/status}" +RUNTIME_ROOT="${ROOT}/.runtime/portal" +RELEASE_ID="${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-runtime-105.XXXXXX")" +BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz" +MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt" +SHA_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256" +DRY_RUN=0 +SKIP_BUILD=0 +SKIP_TESTS=0 +AUTO_YES=0 + +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Usage: + bash scripts/release-portal-runtime-105.sh [--dry-run] [--skip-build] [--skip-tests] [--yes] + +Behavior: + 1. Optionally runs targeted local tests + 2. Builds the local Portal runtime artifact + 3. Packages .runtime/portal into a tarball + 4. Uploads the tarball to 105 + 5. Replaces the 105 live runtime while preserving config and persisted data + +Preserved on 105: + .env, MindSpace/, data/, users/, logs/, public/plaza-covers/ +EOF +} + +say() { + printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + DRY_RUN=1 + ;; + --skip-build) + SKIP_BUILD=1 + ;; + --skip-tests) + SKIP_TESTS=1 + ;; + --yes) + AUTO_YES=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +cd "${ROOT}" + +say "检查 105 SSH" +ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-105-ssh-ok" >/dev/null + +if [[ "${SKIP_TESTS}" -ne 1 ]]; then + say "运行微信相关本地测试" + node --test wechat-mp.test.mjs wechat-oauth.test.mjs wechat-pay.test.mjs user-auth.test.mjs capabilities.test.mjs llm-providers.test.mjs +fi + +if [[ "${SKIP_BUILD}" -ne 1 ]]; then + say "构建 Portal runtime" + PORTAL_RUNTIME_NODE_TARGET=node22 PORTAL_RUNTIME_INSTALL_MODE=remote-npm-install \ + node scripts/build-portal-runtime.mjs --skip-node-modules +fi + +verify_runtime_artifact() { + local missing=0 + for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do + if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then + echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 + missing=1 + fi + done + if [[ "${missing}" -ne 0 ]]; then + echo "请先执行: node scripts/build-portal-runtime.mjs" >&2 + exit 1 + fi +} + +verify_runtime_artifact + +if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then + say "发布确认" + echo "目标主机: ${HOST}" + echo "目标目录: ${APP_DIR}" + echo "systemd 服务: ${SERVICE_NAME}" + echo "发布编号: ${RELEASE_ID}" + echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)" + read -r -p "确认继续发布到 105? [y/N] " confirm /dev/null || echo unknown)" + echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)" + echo "artifact=.runtime/portal" + echo "target=105" + echo "persisted_items=.env, MindSpace, data, users, logs, public/plaza-covers" +} > "${MANIFEST_PATH}" + +say "打包 Portal runtime artifact" +tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" . +checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')" +printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}" + +if [[ "${DRY_RUN}" -eq 1 ]]; then + say "dry-run 完成" + ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" + exit 0 +fi + +say "上传 Portal runtime 到 105" +ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" +scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/" + +say "在 105 切换到新的 runtime" +ssh -o BatchMode=yes "${HOST}" \ + "RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' SERVICE_NAME='${SERVICE_NAME}' /bin/bash" <<'REMOTE_SCRIPT' +set -euo pipefail + +RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}" +OLD_LIVE_DIR="${ARCHIVE_DIR}/h5-before-${RELEASE_ID}" +BUNDLE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz" +MANIFEST="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt" +SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256" +FULL_BACKUP_TAR="${BACKUP_DIR}/h5-full-${RELEASE_ID}-before.tar.gz" +PERSIST_BACKUP_TAR="${BACKUP_DIR}/h5-persisted-${RELEASE_ID}-before.tar.gz" +PERSISTED_ITEMS=( + ".env" + "MindSpace" + "data" + "users" + "logs" + "public/plaza-covers" +) + +say() { + printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +rollback() { + if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then + mv "${OLD_LIVE_DIR}" "${APP_DIR}" + fi + systemctl restart "${SERVICE_NAME}" >/dev/null 2>&1 || true +} + +trap 'rollback' ERR + +[[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; } +[[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; } +[[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; } +[[ -f "${APP_DIR}/.env" ]] || { echo "missing required config: ${APP_DIR}/.env" >&2; exit 1; } + +say "校验产物完整性" +cd "${INCOMING_DIR}" +shasum -a 256 -c "$(basename "${SHA_FILE}")" + +say "备份当前 live 全目录" +COPYFILE_DISABLE=1 tar -czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" + +say "单独备份持久目录" +tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-105-persisted.XXXXXX")" +for item in "${PERSISTED_ITEMS[@]}"; do + if [[ -e "${APP_DIR}/${item}" ]]; then + mkdir -p "${tmp_persist_dir}/$(dirname "${item}")" + cp -a "${APP_DIR}/${item}" "${tmp_persist_dir}/$(dirname "${item}")/" + fi +done +COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" . +rm -rf "${tmp_persist_dir}" + +say "停止旧 Portal 服务" +systemctl stop "${SERVICE_NAME}" +lsof -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true +sleep 2 + +say "准备新的 runtime live 目录" +rm -rf "${RUNTIME_DIR}" +mkdir -p "${RUNTIME_DIR}" +tar -xzf "${BUNDLE}" -C "${RUNTIME_DIR}" +cp "${MANIFEST}" "${RUNTIME_DIR}/.release-manifest.txt" + +for item in "${PERSISTED_ITEMS[@]}"; do + if [[ -e "${APP_DIR}/${item}" ]]; then + rm -rf "${RUNTIME_DIR:?}/${item}" + mkdir -p "$(dirname "${RUNTIME_DIR}/${item}")" + cp -a "${APP_DIR}/${item}" "$(dirname "${RUNTIME_DIR}/${item}")/" + fi +done + +say "在 105 安装 Linux 运行依赖" +cd "${RUNTIME_DIR}" +rm -rf node_modules package-lock.json +npm install --omit=dev --no-package-lock + +say "归档旧 live 并切换新 runtime" +rm -rf "${OLD_LIVE_DIR}" +mv "${APP_DIR}" "${OLD_LIVE_DIR}" +mv "${RUNTIME_DIR}" "${APP_DIR}" + +say "启动新的 Portal runtime" +systemctl restart "${SERVICE_NAME}" + +say "健康检查" +for _ in $(seq 1 60); do + portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" + if [[ "${portal_code}" == "200" ]]; then + break + fi + sleep 2 +done + +portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)" +if [[ "${portal_code}" != "200" ]]; then + echo "health check failed: portal=${portal_code}" >&2 + exit 1 +fi + +say "runtime 切换完成" +REMOTE_SCRIPT + +say "105 runtime 发布完成" diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index e500d56..a41e7e4 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -191,37 +191,22 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n fi fi else + api_secret="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)" for port in 18006 18007; do - line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')" - if [[ -z "${line}" ]]; then - echo "goosed dependency check failed: port ${port} has no listener" >&2 + status="$(curl -skS -m 5 -H "X-Secret-Key: ${api_secret}" "https://127.0.0.1:${port}/status" 2>/dev/null || true)" + if [[ "${status}" != "ok" ]]; then + line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')" + echo "goosed dependency check failed: port ${port} status check failed (${line:-no listener})" >&2 missing=1 - continue fi - command_name="${line%% *}" - case "${command_name}" in - goosed) ;; - *) - echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2 - missing=1 - ;; - esac done - if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then - echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2 - missing=1 - fi - if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then - echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2 - missing=1 - fi - if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then - echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2 - missing=1 - fi - if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then - echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2 + portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)" + portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)" + portal_mcp_node="${portal_mcp_node:-/opt/homebrew/opt/node@24/bin/node}" + portal_mcp_server="${portal_mcp_server:-/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs}" + if [[ ! -x "${portal_mcp_node}" || ! -f "${portal_mcp_server}" ]]; then + echo "goosed dependency check failed: Portal MCP paths are not valid: ${portal_mcp_node}, ${portal_mcp_server}" >&2 missing=1 fi fi diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh index 1f14012..5db5edb 100755 --- a/scripts/run-memind-portal-prod.sh +++ b/scripts/run-memind-portal-prod.sh @@ -14,6 +14,7 @@ fi export NODE_ENV=production export H5_PORT="${H5_PORT:-8081}" +export H5_HOST="${H5_HOST:-127.0.0.1}" export H5_PUBLIC_BASE_URL="https://m.tkmind.cn" export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}" export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}" @@ -24,4 +25,7 @@ if [[ ! -x "${NODE_BIN}" ]]; then NODE_BIN="$(command -v node)" fi +export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}" +export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}" + exec "${NODE_BIN}" server.mjs