Compare commits

...

7 Commits

Author SHA1 Message Date
john cf04e73f51 docs(ops): 同步 103 生产 goosed 扩容为 9 实例
确认 goosed-prod-1..9 映射 18006-18014,更新 topology、发包必看、
发布 skill 与 release 脚本的动态 pool 校验。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 09:22:40 +08:00
john 5485cc563b merge origin/main: 合并 runtime bundle 修复与本地图片物化/build 修复 2026-07-07 09:09:32 +08:00
tkmind 0edd531ed9 Merge pull request 'fix(ops): 将 goose session postgres ensure 脚本打入 runtime 包' (#8) from fix/goose-session-postgres-runtime-bundle into main 2026-07-06 09:11:59 +00:00
john 84bc03c9ba fix(ops): 将 goose session postgres ensure 脚本打入 runtime 包
发版后 Portal live 目录为无源码 runtime,ensure 脚本需随包发布;文档与发布检查改为引用 goosed-prod 稳定路径作为 103 兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:11:40 +08:00
john 3befb0e257 fix(build): runtime 清理失败时回退 shell rm -rf
避免 macOS 上 pnpm node_modules 树导致 ENOTEMPTY 阻断发布构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:06:06 +08:00
john 0ae7a6a677 fix(mindspace): 公开页上传图片物化到 static 路径供转发访问
Finish 同步与首次访问时将私有资产 API 图片复制到 public/.tmp-images,
避免其他用户转发链接后因无登录态无法加载页面内图片。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:03:47 +08:00
tkmind 1077dd9b97 Merge pull request 'fix(ops): 自愈 goosed 会话 PostgreSQL,防止 H5 卡在创建新对话' (#7) from fix/goose-session-postgres-ensure into main 2026-07-06 09:01:25 +00:00
15 changed files with 282 additions and 44 deletions
+4 -4
View File
@@ -74,12 +74,12 @@ curl -k -i https://m.tkmind.cn/auth/login \
必须是 **401** + `用户名或密码错误`。如果是 `503` + `未配置用户数据库或访问密码`,说明用户系统 bootstrap 失败——不是简单健康检查能发现的问题,必须当场处理,不能算发布成功。
### 3.2 Portal + 个 goosed 实例(SSH 103
### 3.2 Portal + 个 goosed 实例(SSH 103
```bash
ssh john@58.38.22.103 '
printf "portal="; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/api/status
for p in 18006 18007 18008 18009; do
for p in $(seq 18006 18014); do
printf "goosed_${p}="
curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status
done
@@ -89,7 +89,7 @@ ssh john@58.38.22.103 '
'
```
必须全部是 `200`,五个 env var 全部 `SET`
必须全部是 `200`Portal + 9 个 goosed target,五个 env var 全部 `SET`
### 3.3 日志关键词检查(SSH 103
@@ -164,7 +164,7 @@ ssh john@58.38.22.103 '
- persisted backup 路径
- Portal 健康结果(200/非200
- `/auth/login` 结果(401 + 正确消息,还是别的)
- 18006-18009 健康结果
- `18006``18014` 健康结果(共 9 个 target
- 日志关键词检查结果
- 第 3.4 节真实业务路径验证结果(含遗留的测试 page id,交给用户决定是否清理)
- 是否发现并处理了问题;如果发布后发现回滚,说明触发原因
+13 -6
View File
@@ -1,6 +1,6 @@
# Local and 103 runtime topology
> Last confirmed: 2026-07-03 15:36 CST.
> Last confirmed: 2026-07-07 09:19 CST.
>
> This is the current topology source of truth for local Memind and 103 production. Prefer this document over older migration notes. Older architecture documents may contain historical paths from before the MindSpace split.
@@ -19,7 +19,7 @@ Local Memind connects to native `goosed`, not Colima/Docker `goosed`.
Local ports `18007`-`18009` are not the intended local goosed pool. If they are listening locally, treat that as drift unless a specific local test explicitly opened them.
Local Colima may exist for other workloads, but local Memind/goosed is not supposed to route through the 103-style `goosed-prod-1..4` Colima pool.
Local Colima may exist for other workloads, but local Memind/goosed is not supposed to route through the 103-style `goosed-prod-1..9` Colima pool.
## Portal
@@ -31,6 +31,7 @@ Local Colima may exist for other workloads, but local Memind/goosed is not suppo
| Start script | `/Users/john/Project/Memind/scripts/run-memind-portal-prod.sh` |
| Port | `8081` |
| Health check | `curl http://127.0.0.1:8081/api/status` |
| Upstream goosed pool | `TKMIND_API_TARGETS``https://127.0.0.1:18006``18014`9 targets2026-07-07 确认) |
Portal must be released as a runtime artifact. Do not edit source directly on 103.
@@ -64,13 +65,13 @@ Portal must be released as a runtime artifact. Do not edit source directly on 10
**Critical:** Colima goosed reaches the session DB via `host.docker.internal:5432`. If host PostgreSQL is stopped, H5 `/agent/start` hangs ~60s then fails with `pool timed out`, and the UI stays on「正在创建新对话…」. Recovery:
```bash
ssh john@58.38.22.103 'bash /Users/john/Project/Memind/scripts/ensure-goose-session-postgres.sh'
ssh john@58.38.22.103 'bash /Users/john/Project/goosed-prod/scripts/ensure-goose-session-postgres.sh'
ssh john@58.38.22.103 '/opt/homebrew/opt/postgresql@17/bin/pg_isready -h 127.0.0.1 -p 5432'
```
`cn.tkmind.goosed-monitor`103)运行 `goosed-prod/scripts/monitor-goosed-containers.mjs`,本地 native goosed 监控用 `scripts/monitor-goosed-fds.mjs`;两者都会在巡检时调用 `ensure-goose-session-postgres.sh`
Container layout:
Container layout (host port → container `18006`):
| Container | Host port | Container port |
|-----------|-----------|----------------|
@@ -78,8 +79,13 @@ Container layout:
| `goosed-prod-2` | `18007` | `18006` |
| `goosed-prod-3` | `18008` | `18006` |
| `goosed-prod-4` | `18009` | `18006` |
| `goosed-prod-5` | `18010` | `18006` |
| `goosed-prod-6` | `18011` | `18006` |
| `goosed-prod-7` | `18012` | `18006` |
| `goosed-prod-8` | `18013` | `18006` |
| `goosed-prod-9` | `18014` | `18006` |
All four ports must return `ok` from `/status`.
All nine host ports must return `ok` from `/status`, and must appear in Portal `.env` `TKMIND_API_TARGETS`.
## PostgreSQL rule
@@ -119,5 +125,6 @@ ssh john@58.38.22.103 '/opt/homebrew/bin/docker inspect goosed-prod-1 --format "
```bash
ssh john@58.38.22.103 'curl -fsS http://127.0.0.1:8081/api/status'
ssh john@58.38.22.103 'curl -fsS http://127.0.0.1:8082/health'
ssh john@58.38.22.103 'for p in 18006 18007 18008 18009; do curl -kfsS https://127.0.0.1:$p/status; echo; done'
ssh john@58.38.22.103 'for p in $(seq 18006 18014); do curl -kfsS https://127.0.0.1:$p/status; echo; done'
ssh john@58.38.22.103 'grep ^TKMIND_API_TARGETS= /Users/john/Project/Memind/.env'
```
@@ -688,7 +688,7 @@ Data boundary:
- LaunchAgent 参数包含 `--prune --retention-days 30`
- 只清理 `reports/runtime-slo` 顶层过期 `.json/.md`
- 最新报告 `reportPrune.errors=[]`
- P5.14 已给四个 goosed worker 增加常驻 sidecar heartbeat:
- P5.14 已给全部 goosed worker(当前 9 个,`18006..18014`增加常驻 sidecar heartbeat:
- `goosed-1`: `statusCode=200`, `latencyMs=62`
- `goosed-2`: `statusCode=200`, `latencyMs=46`
- `goosed-3`: `statusCode=200`, `latencyMs=46`
@@ -54,8 +54,8 @@
- Memind Portal: `/Users/john/Project/Memind/server.mjs`,本机 `:8081`
- H5 public base: `https://mm.tkmind.cn``m.tkmind.cn` 暂时不再作为 H5 public base。
- goosed worker pool: Docker/Colima 内 `goosed-prod-1..4`,宿主机 `18006..18009`,当前健康
- H5 上游配置: `TKMIND_API_TARGETS=https://127.0.0.1:18006,...,18009`
- goosed worker pool: Docker/Colima 内 `goosed-prod-1..9`,宿主机 `18006..18014`2026-07-07 确认 9 个 healthy
- H5 上游配置: `TKMIND_API_TARGETS=https://127.0.0.1:18006,...,18014`(须与 compose 容器数一致)
- 当前已有 session affinity: start 时分配 worker,后续 reply/events 回到同一 worker。
- imgproxy: 原生 `127.0.0.1:20082`,兼容代理 `10.10.0.2:20081`
- 风险点: SSE 代理尚未完整处理 `flushHeaders``X-Accel-Buffering`、客户端断开 abort、写入 backpressure 和统一 pipeline 收尾。
@@ -113,7 +113,7 @@ flowchart LR
- `node --check /Users/john/Project/Memind/server.mjs` 通过。
- 本机 `http://127.0.0.1:8081/api/status` 仍返回 `ok`
- 四个 `https://127.0.0.1:18006..18009/status` 仍返回 `ok`
- 全部 `https://127.0.0.1:18006..18014/status` 仍返回 `ok`
### P1: Gateway SSE 配置
@@ -296,7 +296,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- Portal 新 PID: `94324`,监听 `*:8081`
- 运行环境确认: `H5_PUBLIC_BASE_URL=https://mm.tkmind.cn``VITE_MINDSPACE_BASE=https://mm.tkmind.cn`
- 本机 `http://127.0.0.1:8081/api/status` 返回 `ok`
- 个 goosed worker `18006..18009` 均返回 `ok`
- 个 goosed worker `18006..18014` 均返回 `ok`
- 公网 `https://mm.tkmind.cn/api/status` 当前返回 nginx `502 Bad Gateway`,说明域名侧入口尚未接到当前 Portal;该问题纳入 P1 新入口修复,不再回到 105 转发链路处理。
### 2026-07-02 P1 Gateway SSE 配置
@@ -376,7 +376,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `memind:runtime:worker:goosed-3:heartbeat=<timestamp>`
- `memind:runtime:stream:20260701_24:status=active`
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- 个 goosed worker `18006..18009` 均返回 `ok`
- 个 goosed worker `18006..18014` 均返回 `ok`
### 2026-07-02 P3 Observability v1
@@ -400,7 +400,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `/opt/homebrew/opt/node@24/bin/node --check /Users/john/Project/Memind/server.mjs` 通过。
- `https://mm.tkmind.cn/api/runtime/status` 返回 `ok: true`
- 返回中 `router.enabled=true`namespace 为 `memind:runtime`
- 返回中个目标 `18006..18009``healthy: true`
- 返回中个目标 `18006..18014``healthy: true`
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- 未登录访问 `https://mm.tkmind.cn/api/sessions/test-session/events` 返回 `401`,且响应头包含 `x-accel-buffering: no`
@@ -577,7 +577,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
生产验证:
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `https://mm.tkmind.cn/api/runtime/status` 返回 router enabled四个 goosed target healthy。
- `https://mm.tkmind.cn/api/runtime/status` 返回 router enabled全部 goosed target healthy。
- `scripts/check-stream-runtime.mjs` 返回 `ok=true`
- `scripts/check-tool-runtime.mjs` 返回 `ok=true`,普通 chat 不注入 Aider/OpenHandscode mode 对白名单用户可注入。
- `scripts/runtime-worker-drain.mjs status` 返回 `ok=true`,四个 worker drain=false。
@@ -678,7 +678,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `https://mm.tkmind.cn/api/status` 返回 `ok`
- `/api/runtime/status.toolRuntime.codeRunsEnabled=false`
- 四个 goosed target healthy。
- 全部 goosed target healthy。
- `scripts/check-agent-code-run-entry.mjs` 返回 `ok=true`
- `scripts/check-stream-runtime.mjs` 返回 `ok=true`
- `scripts/runtime-worker-drain.mjs status` 返回 `ok=true`,四个 worker drain=false。
@@ -752,7 +752,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
- `status`:采样但不写 Redis。
- `sample`:采样并写入 Redis。
- `--dry-run`:强制只读。
- 对 Docker worker `goosed-prod-1..4` 采集:
- 对 Docker worker `goosed-prod-1..9` 采集:
- `cpuLoad`
- `memoryPressure`
- `fdPressure`
@@ -789,7 +789,7 @@ docker exec memind-runtime-redis redis-cli DEL memind:runtime:worker:goosed-3:dr
生产验证:
- `node scripts/runtime-worker-metrics.mjs sample` 返回 `ok=true`四个 `goosed-prod-*``healthy`
- `node scripts/runtime-worker-metrics.mjs sample` 返回 `ok=true`全部 `goosed-prod-*``healthy`
- `/api/runtime/status.router.workers[]` 已显示:
- `cpuLoad`
- `memoryPressure`
+1 -1
View File
@@ -109,7 +109,7 @@ Plaza 本地开发说明见 [plaza-local.md](./plaza-local.md)。生产发布、
| 项 | 本地(本机 `.env` / `../../.env.local` | 103 生产(服务器 `/Users/john/Project/Memind/.env` |
|---|---|---|
| 配置真相 | 复制 `.env.example`,只填本地联调值 | 仅在 103 维护,**不提交 Git** |
| `TKMIND_API_TARGET` | 通常单实例 `https://127.0.0.1:18006` | goosed 实例,见 [103 runtime topology](./103-runtime-topology.md) |
| `TKMIND_API_TARGET` | 通常单实例 `https://127.0.0.1:18006` | 9 个 goosed target`18006``18014`,见 [103 runtime topology](./103-runtime-topology.md) |
| `H5_PUBLIC_BASE_URL` | `http://127.0.0.1:5173` | `https://mm.tkmind.cn` |
| `MEMIND_SESSION_BROKER_ENABLED` | Patch 2 起可本地设 `1` 验证 | 灰度窗口由运维在 103 `.env` 单独开启 |
| RDS / Redis | 可连本地 MySQL 或留空 | 生产 RDS + Redis,见 103 `.env` |
@@ -45,7 +45,7 @@
| 检查项 | 结果 |
|--------|------|
| Portal `8081/api/status` | 200 |
| goosed 1800618009 `/status` | 200 |
| goosed `18006``18014` `/status` | 2002026-07-07 起为 9 个 target0706007 时代为 4 个) |
| `https://m.tkmind.cn/auth/login` 错误密码 | 401 + `用户名或密码错误` |
| Portal 日志关键词 | 无异常关键词 |
| memind_adm API `8085/health` | 200 |
+11 -14
View File
@@ -86,7 +86,7 @@ curl -k -i https://m.tkmind.cn/auth/login \
```bash
ssh john@58.38.22.103 '
printf "portal="; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8081/api/status
for p in 18006 18007 18008 18009; do
for p in $(seq 18006 18014); do
printf "goosed_${p}="
curl -k -s -o /dev/null -w "%{http_code}\n" https://127.0.0.1:${p}/status
done
@@ -100,10 +100,7 @@ ssh john@58.38.22.103 '
必须看到:
- `portal=200`
- `goosed_18006=200`
- `goosed_18007=200`
- `goosed_18008=200`
- `goosed_18009=200`
- `goosed_18006=200``goosed_18014=200`(共 9 个 target
- `DATABASE_URL=SET`
- `TKMIND_API_TARGETS=SET`
- `GOOSED_MCP_NODE_PATH=SET`
@@ -193,10 +190,10 @@ TKMIND_ENABLE_CODE_EXECUTION_EXTENSION=1
Portal 现在使用:
```bash
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
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,https://127.0.0.1:18010,https://127.0.0.1:18011,https://127.0.0.1:18012,https://127.0.0.1:18013,https://127.0.0.1:18014
```
发布或重建 goosed 容器后,要确认 18006-18009 都有 provider 配置。否则 Agent 会出现:
发布或重建 goosed 容器后,要确认 `18006``18014` 全部 target 都有 provider 配置。否则 Agent 会出现:
```text
Provider 'custom_deepseek' is not configured
@@ -233,7 +230,7 @@ ssh john@58.38.22.103 '
export PORTAL_RUNTIME_DIR="$portal_runtime_dir" MINDSPACE_ROOT="$mindspace_root"
set +a
/opt/homebrew/bin/docker compose -f docker-compose.prod.yml ps
for c in goosed-prod-1 goosed-prod-2 goosed-prod-3 goosed-prod-4; do
for c in goosed-prod-{1..9}; do
echo "--- $c"
/opt/homebrew/bin/docker exec "$c" sh -lc "
ls -l /usr/local/bin/node \
@@ -248,9 +245,9 @@ ssh john@58.38.22.103 '
重建容器后必须重新确认:
- 个容器都是 `healthy`
- 18006-18009 `/status` 都是 200。
- provider 已同步到四个 target。
- 个容器都是 `healthy`
- `18006``18014` `/status` 都是 200。
- provider 已同步到全部 target。
- sandbox-fs smoke 能成功 `add extension` 并完成一次 `/reply`
## 6. 出问题时先走这条判断链
@@ -259,9 +256,9 @@ ssh john@58.38.22.103 '
2. `/auth/login` 是否进入 400/401,而不是 503
3. 103 Portal 进程是否拿到了 `DATABASE_URL`
4. `~/Library/Logs/memind-portal.log` 是否有 `User auth bootstrap failed`
5. 18006-18009 是否全 200
5. `18006``18014` 是否全 200
6. goosed 容器内 `/usr/local/bin/node``/opt/portal/mindspace-sandbox-mcp.mjs` 是否存在?
7. provider 是否同步到四个 target
7. provider 是否同步到全部 target
8. 如果是微信服务号或专属 Agent 报错,必须测真实业务路径,不要只看健康接口。
## 7. 发布完成后给用户的最小回报格式
@@ -274,7 +271,7 @@ ssh john@58.38.22.103 '
- persisted backup
- Portal 健康结果
- `/auth/login` 结果
- 18006-18009 健康结果
- `18006``18014` 健康结果(共 9 个 target
- 是否发现并处理了日志里的错误关键词
不要只说“已发布成功”。
+50
View File
@@ -50,6 +50,7 @@ export async function materializePrivateAssetsInWorkspaceHtml({
htmlRelativePath,
writeBack = false,
}) {
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)];
if (matches.length === 0) {
return { html, count: 0, changed: false };
@@ -106,6 +107,55 @@ export async function materializePrivateAssetsInWorkspaceHtml({
return { html: result, count, changed: true };
}
export function htmlReferencesPrivateAssets(html) {
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
const found = PRIVATE_ASSET_URL_PATTERN.test(String(html ?? ''));
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
return found;
}
export async function materializePrivateAssetsInPublicHtmlFiles({
pool,
storageRoot,
h5Root,
userId,
publishDir,
relativePaths = [],
}) {
const materialized = [];
const skipped = [];
const uniquePaths = [...new Set(relativePaths)].filter(Boolean);
for (const htmlRelativePath of uniquePaths) {
const htmlPath = path.join(publishDir, htmlRelativePath);
let html;
try {
html = await fs.readFile(htmlPath, 'utf8');
} catch {
skipped.push(htmlRelativePath);
continue;
}
if (!htmlReferencesPrivateAssets(html)) {
skipped.push(htmlRelativePath);
continue;
}
const result = await materializePrivateAssetsInWorkspaceHtml({
pool,
storageRoot,
h5Root,
userId,
html,
htmlRelativePath,
writeBack: true,
});
if (result.changed) {
materialized.push({ relativePath: htmlRelativePath, count: result.count });
} else {
skipped.push(htmlRelativePath);
}
}
return { materialized, skipped };
}
export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) {
const collectIds = (text) => [
...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])),
+39
View File
@@ -14,6 +14,7 @@ import {
findPublishHtml,
injectHtmlBaseHref,
materializePrivateAssetsInWorkspaceHtml,
materializePrivateAssetsInPublicHtmlFiles,
repairMissingHtmlAssetReferences,
resolveClosestHtmlRelativePath,
resolveChatSaveAnalysis,
@@ -226,6 +227,44 @@ test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-image
await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`));
});
test('materializePrivateAssetsInPublicHtmlFiles rewrites referenced public html files', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-batch-'));
const storageRoot = path.join(root, 'storage');
const assetId = '44444444-4444-4444-8444-444444444444';
const userId = USER_ID;
const storageKey = `users/${userId}/assets/${assetId}/versions/v1`;
const sourcePath = path.join(storageRoot, storageKey);
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
const publishDir = path.join(root, 'MindSpace', userId);
const htmlRelativePath = 'public/share.html';
const htmlPath = path.join(publishDir, htmlRelativePath);
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
const html = `<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`;
await fs.writeFile(htmlPath, html, 'utf8');
const pool = {
query: async () => [
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }],
],
};
const result = await materializePrivateAssetsInPublicHtmlFiles({
pool,
storageRoot,
h5Root: root,
userId,
publishDir,
relativePaths: [htmlRelativePath],
});
assert.equal(result.materialized.length, 1);
assert.equal(result.materialized[0].relativePath, htmlRelativePath);
const saved = await fs.readFile(htmlPath, 'utf8');
assert.match(saved, /\.tmp-images\/44444444-4444-4444-8444-444444444444\.jpg/);
});
test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => {
const validId = '22222222-2222-4222-8222-222222222222';
const invalidId = '33333333-3333-4333-8333-333333333333';
+24 -2
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
import { extractStaticPageLinks, materializePrivateAssetsInPublicHtmlFiles } from './mindspace-chat-save.mjs';
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs';
@@ -480,6 +480,9 @@ export async function syncPublicHtmlAfterFinish({
syncWorkspaceAssets,
registerPublicHtmlArtifacts,
sessionId = null,
pool = null,
storageRoot = null,
h5Root = null,
} = {}) {
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
return { materialized: [], skipped: [], synced: false };
@@ -496,6 +499,17 @@ export async function syncPublicHtmlAfterFinish({
});
const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath);
const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null;
let assetMaterialization = { materialized: [], skipped: [] };
if (pool && storageRoot && h5Root && currentUser?.id && publicHtmlRelativePaths.length > 0) {
assetMaterialization = await materializePrivateAssetsInPublicHtmlFiles({
pool,
storageRoot,
h5Root,
userId: currentUser.id,
publishDir,
relativePaths: publicHtmlRelativePaths,
}).catch(() => ({ materialized: [], skipped: publicHtmlRelativePaths }));
}
let synced = false;
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
await syncWorkspaceAssets(currentUser.id, {
@@ -512,5 +526,13 @@ export async function syncPublicHtmlAfterFinish({
artifactRefs: publicHtmlArtifactRefs,
});
}
return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs };
return {
materialized,
skipped,
synced,
docxSync,
publicHtmlRelativePaths,
publicHtmlArtifactRefs,
assetMaterialization,
};
}
+52
View File
@@ -249,6 +249,58 @@ test('syncPublicHtmlAfterFinish registers existing public html artifacts for the
}
});
test('syncPublicHtmlAfterFinish materializes private image assets into public html files', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-assets-'));
const publishDir = path.join(root, 'MindSpace', CURRENT_USER.id);
const storageRoot = path.join(root, 'storage');
const assetId = '55555555-5555-4555-8555-555555555555';
const storageKey = `users/${CURRENT_USER.id}/assets/${assetId}/versions/v1`;
fs.mkdirSync(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
fs.writeFileSync(path.join(storageRoot, storageKey), Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
try {
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
fs.writeFileSync(
path.join(publishDir, 'public/tea.html'),
`<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`,
);
const messages = [
{
id: 'msg-tea-1',
role: 'assistant',
content: [
{
type: 'text',
text: `[茶馆](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/tea.html)`,
},
],
},
];
const pool = {
query: async () => [
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'tea.jpg', storage_key: storageKey }],
],
};
const result = await syncPublicHtmlAfterFinish({
messages,
currentUser: CURRENT_USER,
publishDir,
sessionId: 'session-tea',
pool,
storageRoot,
h5Root: root,
syncWorkspaceAssets: async () => {},
});
assert.equal(result.assetMaterialization.materialized.length, 1);
const saved = fs.readFileSync(path.join(publishDir, 'public/tea.html'), 'utf8');
assert.match(saved, /\.tmp-images\/55555555-5555-4555-8555-555555555555\.jpg/);
assert.ok(fs.existsSync(path.join(publishDir, 'public/.tmp-images', `${assetId}.jpg`)));
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
try {
+12 -1
View File
@@ -65,7 +65,13 @@ async function exists(targetPath) {
}
async function remove(targetPath) {
await fs.rm(targetPath, { recursive: true, force: true });
if (!(await exists(targetPath))) return;
try {
await fs.rm(targetPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
} catch (error) {
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EBUSY') throw error;
await run('rm', ['-rf', targetPath]);
}
}
async function copyDir(source, target) {
@@ -352,6 +358,10 @@ async function writeMetadata() {
path.join(root, 'scripts', 'resume-agent-run-prod.sh'),
path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'),
);
await fs.copyFile(
path.join(root, 'scripts', 'ensure-goose-session-postgres.sh'),
path.join(runtimeRoot, 'scripts', 'ensure-goose-session-postgres.sh'),
);
await writeFile(
path.join(runtimeRoot, 'RUNBOOK.txt'),
[
@@ -463,6 +473,7 @@ async function main() {
await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-guard.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-agent-run-guard-agent.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'resume-agent-run-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'ensure-goose-session-postgres.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-agent-run-worker.mjs'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'memind-portal-tunnel.sh'), 0o755);
+34 -3
View File
@@ -205,7 +205,7 @@ pg_isready_bin="/opt/homebrew/opt/postgresql@17/bin/pg_isready"
if [[ -x "${pg_isready_bin}" ]]; then
if ! "${pg_isready_bin}" -h 127.0.0.1 -p 5432 -q 2>/dev/null; then
echo "goosed dependency check failed: host PostgreSQL (127.0.0.1:5432) is not accepting connections" >&2
echo "Run: bash /Users/john/Project/Memind/scripts/ensure-goose-session-postgres.sh" >&2
echo "Run: bash /Users/john/Project/goosed-prod/scripts/ensure-goose-session-postgres.sh" >&2
missing=1
fi
else
@@ -214,7 +214,17 @@ fi
docker_bin="/opt/homebrew/bin/docker"
if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/null | grep -q '^goosed-prod-1$'; then
for index in 1 2 3 4; do
goosed_indexes=()
while IFS= read -r name; do
if [[ "${name}" =~ ^goosed-prod-([0-9]+)$ ]]; then
goosed_indexes+=("${BASH_REMATCH[1]}")
fi
done < <("${docker_bin}" ps --format '{{.Names}}' | sort -V)
if ((${#goosed_indexes[@]} == 0)); then
echo "goosed dependency check failed: no goosed-prod-* containers running" >&2
missing=1
fi
for index in "${goosed_indexes[@]}"; do
container="goosed-prod-${index}"
host_port=$((18005 + index))
if ! "${docker_bin}" ps --format '{{.Names}} {{.Ports}} {{.Status}}' | grep -q "^${container} .*0.0.0.0:${host_port}->18006/tcp.*healthy"; then
@@ -233,7 +243,7 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
echo "goosed dependency check failed: Portal .env must set GOOSED_MCP_NODE_PATH and GOOSED_MCP_SERVER_PATH for Docker goosed" >&2
missing=1
else
for index in 1 2 3 4; do
for index in "${goosed_indexes[@]}"; do
container="goosed-prod-${index}"
if ! "${docker_bin}" exec "${container}" sh -lc "test -x '${portal_mcp_node}' && test -f '${portal_mcp_server}'" >/dev/null 2>&1; then
echo "goosed dependency check failed: ${container} cannot resolve Portal .env MCP paths '${portal_mcp_node}' and '${portal_mcp_server}'" >&2
@@ -241,6 +251,27 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
fi
done
fi
targets_line="$(grep -E '^TKMIND_API_TARGETS=' /Users/john/Project/Memind/.env | tail -1 | cut -d= -f2- || true)"
if [[ -z "${targets_line}" ]]; then
echo "goosed dependency check failed: Portal .env must set TKMIND_API_TARGETS for Docker goosed pool" >&2
missing=1
else
IFS=',' read -ra target_urls <<< "${targets_line}"
for target_url in "${target_urls[@]}"; do
trimmed="$(echo "${target_url}" | tr -d ' \"')"
if [[ -z "${trimmed}" ]]; then
continue
fi
status="$(curl -skS -m 5 "${trimmed}/status" 2>/dev/null || true)"
if [[ "${status}" != "ok" ]]; then
echo "goosed dependency check failed: ${trimmed}/status != ok (${status:-empty})" >&2
missing=1
fi
done
if ((${#target_urls[@]} != ${#goosed_indexes[@]})); then
echo "goosed dependency check warning: TKMIND_API_TARGETS count (${#target_urls[@]}) != running containers (${#goosed_indexes[@]})" >&2
fi
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)"
+1 -1
View File
@@ -14,7 +14,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"
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_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,https://127.0.0.1:18010,https://127.0.0.1:18011,https://127.0.0.1:18012,https://127.0.0.1:18013,https://127.0.0.1:18014}"
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}"
+29
View File
@@ -116,6 +116,7 @@ import {
injectHtmlBaseHref,
resolveClosestHtmlRelativePath,
createAssetDataUriResolver,
htmlReferencesPrivateAssets,
materializePrivateAssetsInWorkspaceHtml,
repairMissingHtmlAssetReferences,
resolveChatSaveAnalysis,
@@ -4846,6 +4847,9 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
currentUser: req.currentUser,
publishDir,
sessionId: sid,
pool: authPool,
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
h5Root: __dirname,
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
@@ -5728,6 +5732,30 @@ async function sendLongImageDownloadIfRequested(req, res, filePath) {
});
}
async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
if (!authPool || !htmlReferencesPrivateAssets(html)) return html;
const normalized = path.resolve(String(filePath ?? ''));
const ownerMatch = normalized.match(
new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'),
);
const userId = ownerMatch?.[1] ?? null;
if (!userId) return html;
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/');
if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html;
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
const result = await materializePrivateAssetsInWorkspaceHtml({
pool: authPool,
storageRoot,
h5Root: __dirname,
userId,
html,
htmlRelativePath,
writeBack: true,
}).catch(() => ({ html, changed: false }));
return result.changed ? result.html : html;
}
async function sendPublishFile(req, res, filePath) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
@@ -5743,6 +5771,7 @@ async function sendPublishFile(req, res, filePath) {
res.status(404).json({ message: '文件不存在' });
return;
}
html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html);
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
const embed = isPlazaEmbedRequest(req.query);
if (embed) {