chore: add 105 runtime and openhands ops tooling

This commit is contained in:
john
2026-06-29 07:01:26 +08:00
parent 65e8a3a5a8
commit d7cbf3cc96
22 changed files with 1263 additions and 50 deletions
@@ -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;
+4 -3
View File
@@ -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;
@@ -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;
+58
View File
@@ -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:
+88
View File
@@ -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)
+34
View File
@@ -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,
)
+6
View File
@@ -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
+58
View File
@@ -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`
菜单名称与链接定义在本地:
+22
View File
@@ -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
+85 -11
View File
@@ -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
+33
View File
@@ -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`
+24 -3
View File
@@ -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: [
+2
View File
@@ -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",
+21 -4
View File
@@ -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 = [
'@img/sharp-darwin-arm64',
@@ -87,7 +89,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);',
];
@@ -107,7 +109,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);',
];
@@ -184,10 +186,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(
@@ -212,14 +223,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'),
@@ -250,6 +262,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}`,
'',
'Platform agent skills (synced into user workspaces):',
' skills/',
'Key runtime differences must stay in .env, not in the artifact:',
@@ -272,7 +287,9 @@ async function main() {
await copyRuntimeAssets();
await bundleServer();
await bundleSandboxMcp();
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);
+223
View File
@@ -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);
}
+74
View File
@@ -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 <executor> [--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(() => {});
}
+159
View File
@@ -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 <<EOF
用法:
bash scripts/install-memind-dev-launchagent.sh # 安装并启动
bash scripts/install-memind-dev-launchagent.sh --uninstall
环境变量:
ROOT 仓库根目录 (默认: ${ROOT})
LABEL LaunchAgent 标识 (默认: ${LABEL})
NODE_BIN node 可执行文件路径
EOF
}
uninstall() {
if launchctl print "${GUI}/${LABEL}" >/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}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${NODE_BIN}</string>
<string>${DAEMON_SCRIPT}</string>
</array>
<key>WorkingDirectory</key>
<string>${ROOT}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${PATH_EXPORT}</string>
<key>HOME</key>
<string>${HOME}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
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"
+76
View File
@@ -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 <<EOF
Usage: scripts/openhands-colima.sh <pull|up|down|restart|logs|status>
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
+22
View File
@@ -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}" "$@"
+254
View File
@@ -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/tty
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
fi
say "生成发布清单"
{
echo "release_id=${RELEASE_ID}"
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
echo "host=$(hostname)"
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/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 发布完成"
+10 -25
View File
@@ -198,37 +198,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
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}')"
if [[ -z "${line}" ]]; then
echo "goosed dependency check failed: port ${port} has no listener" >&2
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
+4
View File
@@ -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