Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b58cdc2c8 | |||
| 9f7633be24 | |||
| fbd68a36e9 | |||
| be8ce867ca | |||
| 67b9478155 | |||
| 4c2bc95462 | |||
| 835677a285 | |||
| a9fe3d01a7 | |||
| c9b2fcba87 | |||
| b97f607817 | |||
| e59809eff4 | |||
| 19c8cdb970 | |||
| fd56f087c6 | |||
| 25be530d84 | |||
| 2f240e7500 | |||
| 293ac69a76 |
@@ -29,20 +29,39 @@ node --test db.test.mjs capabilities.test.mjs llm-providers.test.mjs wechat-mp.t
|
||||
|
||||
全绿之后才能进入打包发布,不要跳过。
|
||||
|
||||
## 2. 打包 + 发布(唯一入口)
|
||||
## 2. 打包 + 发布
|
||||
|
||||
103 整包替换有两种模式(详见 `PRODUCTION_RELEASE_RULES.md`):
|
||||
|
||||
| 模式 | 命令 | 适用 |
|
||||
|------|------|------|
|
||||
| **标准发布** | `bash scripts/release-portal-runtime-prod.sh --yes` | 默认;须先灰度验收 + 晋升证据 + 完整 Gate |
|
||||
| **快速发布** | `bash scripts/release-portal-fast-prod.sh --yes` | 变更已在本地/CI/Gate 充分验证,跳过灰度晋升与完整 Gate 重跑 |
|
||||
|
||||
先 dry-run 确认构建产物正常(不会碰生产):
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-runtime-prod.sh --skip-tests --dry-run
|
||||
bash scripts/release-portal-runtime-prod.sh --dry-run
|
||||
# 或快速发布预演
|
||||
bash scripts/release-portal-fast-prod.sh --dry-run
|
||||
```
|
||||
|
||||
确认无误后正式发布(`--skip-tests` 是因为第 1 步已经手动跑过;这里不再重复跑脚本内置的窄范围测试):
|
||||
**标准发布**须先完成 `bash scripts/release-portal-canary-prod.sh --yes` 与灰度验收。
|
||||
|
||||
**快速发布**在第 1 步测试全绿后可直接执行(仍会做 103 备份、goosed 预检、8081 健康检查):
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-runtime-prod.sh --skip-tests --yes
|
||||
bash scripts/release-portal-fast-prod.sh --yes
|
||||
```
|
||||
|
||||
标准发布确认无误后:
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-runtime-prod.sh --yes
|
||||
```
|
||||
|
||||
标准发布禁止 `--skip-tests`。快速发布仅在 `--mode fast` 下允许 `--skip-tests`,且须已自行完成第 1 步验证。
|
||||
|
||||
这个脚本会自动完成:
|
||||
|
||||
1. 本机构建 `.runtime/portal`
|
||||
|
||||
@@ -66,7 +66,9 @@ npm run verify:seo-geo
|
||||
npm run verify:seo-discovery
|
||||
```
|
||||
|
||||
发版脚本(`scripts/release-portal-runtime-prod.sh`)当前在不跳过测试时会执行相关 verify;生产 `103` 发布禁止使用 `--skip-tests`,并且仍须通过完整生产发布守门员。
|
||||
发版脚本(`scripts/release-portal-runtime-prod.sh` / `scripts/release-portal-fast-prod.sh`):
|
||||
- **标准发布**(默认):须灰度晋升证据 + 完整 Gate + `verify:mindspace-publish-guards:full`;禁止 `--skip-tests`。
|
||||
- **快速发布**(`--mode fast`):跳过灰度晋升与完整 Gate 重跑;可复用有效 Gate report 或跑最小 smoke;仍禁止在生产用 `--skip-tests` 代替发布前验证(快速模式仅在 `--mode fast` 下允许 `--skip-tests`,且须已自行验证)。
|
||||
|
||||
### 受保护的关键路径
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ git rebase origin/main
|
||||
1. 本机不允许直接 `rsync` 到 `103` 或 `105`。
|
||||
2. **禁止** SSH 登录 `105` 后直接修改业务源码(含 `scripts/wechat-mp-menu.mjs` 等);必须先本地 commit,再按发布流程上线。详见 [105 服务器变更规范](docs/105-server-operations.md)。
|
||||
3. Portal 生产与测试统一走“本机构建 runtime artifact -> 打包发布”,**禁止**在 `103` 解源码包后 `npm install` / `npm run build`。
|
||||
4. Portal 首次生产入口是 `bash scripts/release-portal-canary-prod.sh`;它只安装并启用用户级灰度候选。`bash scripts/release-portal-runtime-prod.sh` 是整包晋升脚本,在同一候选完成灰度验收且晋升证据校验落地前继续禁止非 dry-run。
|
||||
4. Portal 首次生产入口是 `bash scripts/release-portal-canary-prod.sh`(用户级灰度)。整包替换有两种模式:`bash scripts/release-portal-runtime-prod.sh`(**标准发布**,须灰度晋升证据 + 完整 Gate)或 `bash scripts/release-portal-fast-prod.sh`(**快速发布**,已验证变更可跳过灰度晋升与完整 Gate 重跑,仍须备份与健康检查)。详见 [PRODUCTION_RELEASE_RULES.md](PRODUCTION_RELEASE_RULES.md)。
|
||||
5. 发布只能从已验证的完整 `main` 打整包;分支代码必须先合并进 `main`,禁止从功能分支、单个 commit、单个修复或局部差异单包发布。
|
||||
6. 发布前必须确认 CI 已通过,且没有未合并的关键变更。
|
||||
7. 发布来源必须是可追溯 commit,不允许从不明工作区直接出包。
|
||||
@@ -67,7 +67,7 @@ bash scripts/check-release-ready.sh
|
||||
```
|
||||
|
||||
10. 分支落后 `origin/main`、工作区有未提交或未跟踪改动、处于 detached HEAD、或没有明确批准却从 `main` / `master` 发布,均禁止发版。
|
||||
11. 生产 `103` 发布必须完整通过 [生产发布守门员](docs/production-release-guardian.md);Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact。生产发布固定执行 Core + 自动影响域;关键共享路径展开到预定义影响域,未映射变更直接阻断并要求先补映射,不再自动执行完整 187 项。所有被选场景成功后仍须取得明确人工批准。
|
||||
11. 生产 `103` 发布须完整通过 [生产发布守门员](docs/production-release-guardian.md) 或使用已文档化的**快速发布**路径(`scripts/release-portal-fast-prod.sh`)。标准发布 Gate report 必须绑定同一完整 `main` commit 和同一 runtime artifact,并执行 Core + 自动影响域;快速发布可复用已有 Gate report 或改跑最小本地验证,但仍须取得明确人工批准。
|
||||
|
||||
## 5. 文档约束
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
2. **禁止 SSH 登录 `105` 直接修改业务代码**(含服务号菜单脚本 `scripts/wechat-mp-menu.mjs`)。105 上文件是部署产物;变更必须:本地 `test-memind` 修改 → Git commit → 正式发布 → 必要时在目标环境执行 API 同步。详见 [docs/105-server-operations.md](docs/105-server-operations.md)。
|
||||
2. **Portal 生产必须是无源码 runtime 模式**:构建只发生在本机 Mac,产物是 `.runtime/portal/`;`103` 只接收 runtime artifact、继承持久目录、启动服务,**禁止**在 `103` 上 `npm install`、`npm run build` 或保留可运行源码树。
|
||||
2. `MindSpace` 独立服务同样必须走单独 runtime artifact:本地 `node scripts/build-mindspace-service-runtime.mjs` -> `bash scripts/release-mindspace-service-prod.sh` -> 上传 `103` -> 备份 `/Users/john/MindSpace` 与共享 `Memind/.env` -> 原子切换到 `/Users/john/MindSpace` -> 健康检查 `127.0.0.1:8082/health` 与 `/mindspace/v1/contract`;禁止手工 SSH 改线上 `/Users/john/MindSpace` 源码。103 当前拓扑见 [docs/103-runtime-topology.md](docs/103-runtime-topology.md)。
|
||||
3. Portal 生产发布必须先构建候选 runtime,并只通过 `scripts/release-portal-canary-prod.sh` 启用用户级灰度。灰度只能命中明确的不可变用户身份,未命中、身份解析失败或候选不健康必须继续走稳定版本。`scripts/release-portal-runtime-prod.sh` 仍是整包替换脚本,在同一候选的灰度验收和晋升证据校验完成前禁止非 dry-run。
|
||||
3. Portal 生产发布提供两种整包替换模式(均须从完整 `main` 打整包,且先通过 `check-release-ready.sh`):
|
||||
- **标准发布**(默认):`bash scripts/release-portal-runtime-prod.sh` 或 `--mode standard`。须先通过 `scripts/release-portal-canary-prod.sh` 完成用户级灰度验收,再校验 103 灰度晋升证据、完整 Core+Impact Gate、`verify:mindspace-publish-guards:full`,最后替换 8081 live 目录。
|
||||
- **快速发布**:`bash scripts/release-portal-fast-prod.sh` 或 `bash scripts/release-portal-runtime-prod.sh --mode fast`。跳过灰度晋升证据、完整 Gate 重跑和 `publish-guards:full`;若当前 commit+artifact 已有有效 Gate report 则复用,否则只跑最小本地 smoke + `test:release-gate:unit`。仍执行 103 备份、goosed 预检、8081 健康检查与明确人工批准。适用于变更已在本地/CI/Gate 充分验证、需尽快整包上线的场景;**禁止**用 `--skip-tests` 替代发布前验证。
|
||||
- 灰度入口仍是 `scripts/release-portal-canary-prod.sh`;快速发布不替代灰度,只是允许在已验证前提下跳过「灰度→晋升」链条直接整包。
|
||||
4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。
|
||||
5. Portal 发布包不得携带运行态资产;`.env`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。`/Users/john/MindSpace` 是独立 MindSpace Service 的生产根目录,不属于 Portal runtime 包;`/Users/john/Project/Memind/MindSpace` 只允许作为旧链路兼容/存量目录处理,不得再被写成 MindSpace Service 的当前根目录。
|
||||
6. runtime artifact 必须包含 `server.mjs` 与 `mindspace-sandbox-mcp.mjs`(`sandbox-fs` 扩展依赖的独立子进程入口)。
|
||||
|
||||
+14
-10
@@ -122,11 +122,10 @@ export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUr
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||
* Remove image attachments from a persisted message so later turns cannot reuse them.
|
||||
*/
|
||||
export function scrubUserMessageImageAttachments(message) {
|
||||
if (!message || message.role !== 'user') return { message, changed: false };
|
||||
function scrubPersistedImageAttachments(message) {
|
||||
if (!message) return { message, changed: false };
|
||||
|
||||
const metadata =
|
||||
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
|
||||
@@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) {
|
||||
return null;
|
||||
}
|
||||
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
|
||||
const nextText = stripAgentImageText(item.text);
|
||||
const nextText = message.role === 'user' ? stripAgentImageText(item.text) : item.text;
|
||||
if (nextText === item.text) return item;
|
||||
contentChanged = true;
|
||||
return nextText ? { ...item, text: nextText } : null;
|
||||
}).filter(Boolean)
|
||||
: message.content;
|
||||
|
||||
const displayText =
|
||||
typeof metadata.displayText === 'string' ? metadata.displayText : null;
|
||||
const changed = hadImageMetadata || contentChanged;
|
||||
if (!changed) return { message, changed: false };
|
||||
|
||||
@@ -170,12 +167,20 @@ export function scrubUserMessageImageAttachments(message) {
|
||||
...message,
|
||||
content,
|
||||
metadata,
|
||||
...(displayText != null ? {} : {}),
|
||||
},
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove image attachments from a persisted user message so later turns cannot reuse them.
|
||||
* UI displayText / previewImageUrls are preserved for chat history rendering.
|
||||
*/
|
||||
export function scrubUserMessageImageAttachments(message) {
|
||||
if (!message || message.role !== 'user') return { message, changed: false };
|
||||
return scrubPersistedImageAttachments(message);
|
||||
}
|
||||
|
||||
export function messageContentHasImageUrl(content) {
|
||||
if (!Array.isArray(content)) return false;
|
||||
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
|
||||
@@ -203,9 +208,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active
|
||||
|
||||
let changed = false;
|
||||
const nextConversation = conversation.map((message) => {
|
||||
if (message?.role !== 'user') return message;
|
||||
if (String(message?.id ?? '').trim() === activeId) return message;
|
||||
const scrubbed = scrubUserMessageImageAttachments(message);
|
||||
const scrubbed = scrubPersistedImageAttachments(message);
|
||||
if (scrubbed.changed) changed = true;
|
||||
return scrubbed.message;
|
||||
});
|
||||
|
||||
@@ -103,6 +103,30 @@ test('scrubConversationHistoricalImageAttachments keeps only active turn attachm
|
||||
]);
|
||||
});
|
||||
|
||||
test('scrubConversationHistoricalImageAttachments removes assistant image_url parts', () => {
|
||||
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
|
||||
[
|
||||
{
|
||||
id: 'assistant-old',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: '已识别报告' },
|
||||
{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user-new',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '解读报告,生成页面' }],
|
||||
},
|
||||
],
|
||||
'user-new',
|
||||
);
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(conversation[0].content.some((item) => item.type === 'image_url'), false);
|
||||
});
|
||||
|
||||
test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
|
||||
const note = buildCurrentTurnImageScopeNote([
|
||||
{
|
||||
|
||||
@@ -3,6 +3,47 @@
|
||||
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
|
||||
它是分支复用、合并、cherry-pick 和清理前的必查清单。
|
||||
|
||||
## `feature/gate-data01-delivery-keywords`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-14
|
||||
分支 HEAD:`293ac69a`
|
||||
`origin/main` 对应提交:`293ac69a`
|
||||
|
||||
### 原始用途
|
||||
|
||||
DATA-01 提示要求不要停在方案确认,却仍断言回复包含「方案/确认」。改为断言「问卷/后台」交付,并登记微信 image_url 分支处置。
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
|
||||
|
||||
## `feature/wechat-image-url-session-rotate`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
审计日期:2026-08-14
|
||||
分支 HEAD:`41f245c8`
|
||||
`origin/main` 对应提交:`41f245c8`(其后主线继续快进了图片额度回填提交)
|
||||
|
||||
### 原始用途
|
||||
|
||||
Goose native 对历史图片清洗返回 PUT 405,DeepSeek 下一轮拒绝 `image_url`。微信「解读详细报告,做成页面」跟进应换新会话并重新附上报告图,且 VL 后不再把 `imageUrls` 传给文本模型。
|
||||
|
||||
### 验证摘要
|
||||
|
||||
- `node --test wechat-mp.test.mjs`:94 passed
|
||||
- `node --test chat-image-turn-scope.test.mjs tkmind-proxy-vision.test.mjs tkmind-proxy.test.mjs`:53 passed
|
||||
|
||||
### 最终处置
|
||||
|
||||
- 保留本地分支名用于审计追溯。
|
||||
- 不要从该分支继续开发。
|
||||
- 不要 merge、cherry-pick 该分支提交或从该分支构建 runtime/artifact。
|
||||
- 后续开发必须从最新 `origin/main` 新建分支。
|
||||
|
||||
## `fix/image-quota-migration-backfill`
|
||||
|
||||
**状态:禁止再次引用。改动已提交并将随本分支合入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
|
||||
|
||||
@@ -63,7 +63,16 @@
|
||||
|
||||
Core Gate 之外,选择器以当前 103 manifest 中的 `git_head` 为 base commit,比较候选
|
||||
commit 的 changed paths,并按 `release-gate/impact.mjs` 的版本化规则选择业务域和依赖闭包。
|
||||
`PAGE-01`、`PAGE-02`、`DATA-01` 至 `DATA-04` 是真 LLM 交付场景:只在 Page Data / 页面交付
|
||||
产品代码变更时选中。`db.mjs`、`server.mjs` 等共享路径仍展开 DATA/PAGE 的确定性套件,
|
||||
但不自动展开上述真 LLM 场景。文件名含 `image` 不等于 IMGPG;IMGPG 只匹配
|
||||
image-generation / imgproxy / thumbnail / user-image-url 等产品路径。
|
||||
正常风险分层报告中的被选场景必须真实执行,不能标记为 `not_applicable`。
|
||||
同一 artifact SHA 下允许续跑:已通过的 suite 可携带证据,失败项、命令文件变更的 suite、
|
||||
以及新 commit 上的真 LLM suite 必须重跑。端口占用或 Docker 未就绪必须在 suite 开始前预检失败,
|
||||
不得把环境问题记成业务场景失败后再整轮重来。
|
||||
Gitea CI 对同一 `origin/main` SHA 为 `success` 时,发布脚本可跳过与 CI 重复的本地
|
||||
`npm test` / verify;禁止 `--skip-tests`,也不得跳过 Gate report。
|
||||
|
||||
### 3.3 关键路径与阻断规则
|
||||
|
||||
@@ -607,10 +616,13 @@ failed = skipped = blocked = unknown = cleanup_failed = 0
|
||||
`release-gate/release-script.test.mjs`。测试通过不代表已经部署;103 灰度仍须绑定同一
|
||||
commit、artifact 和 Gate report,并完成备份、只读预检及人工批准。
|
||||
|
||||
`scripts/release-portal-runtime-prod.sh` 会替换 8081 live 目录。它只用于同一候选完成灰度
|
||||
验收后的全量晋升;在晋升证据校验完成前继续硬阻断非 dry-run,不能拿灰度入口替代晋升批准。
|
||||
`scripts/release-portal-runtime-prod.sh` 会替换 8081 live 目录。
|
||||
|
||||
`scripts/release-portal-runtime-prod.sh` 必须按以下顺序执行:
|
||||
**标准发布**(默认 `--mode standard`):只用于同一候选完成灰度验收后的全量晋升;在晋升证据校验完成前继续硬阻断非 dry-run。
|
||||
|
||||
**快速发布**(`scripts/release-portal-fast-prod.sh` 或 `--mode fast`):跳过灰度晋升证据与完整 Gate 重跑;若已有有效 Gate report 则复用,否则只跑最小本地验证。仍须 `check-release-ready`、103 备份、goosed 预检、8081 健康检查与明确人工批准。manifest 记录 `release_mode=fast`。
|
||||
|
||||
`scripts/release-portal-runtime-prod.sh` 标准模式必须按以下顺序执行:
|
||||
|
||||
1. `check-release-ready.sh`
|
||||
2. 获取完整 `main` SHA 和远端 CI 状态
|
||||
@@ -623,9 +635,9 @@ commit、artifact 和 Gate report,并完成备份、只读预检及人工批
|
||||
|
||||
以下情况必须直接退出:
|
||||
|
||||
- 使用 `--skip-tests`
|
||||
- **标准发布**使用 `--skip-tests`
|
||||
- 使用范围绕过变量
|
||||
- report 不存在、过期或解析失败
|
||||
- **标准发布**下 report 不存在、过期或解析失败
|
||||
- commit 或 artifact SHA 不一致
|
||||
- 任一被选场景失败、跳过、未知、未执行或清理失败
|
||||
- 影响选择结果不可重现或存在未映射路径
|
||||
|
||||
@@ -67,9 +67,11 @@ node scripts/run-release-gate-impact.mjs --artifact .runtime/portal --deployed-c
|
||||
|
||||
它固定执行 16 项核心场景,再根据 `<103-stable-sha>..HEAD` 的 changed paths 选择业务域及
|
||||
依赖闭包。`server.mjs`、鉴权/会话基础设施、schema/migration、依赖、runtime 构建、
|
||||
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。发布脚本在有效报告
|
||||
缺失或过期时自动执行该入口,不再要求人工先跑多个 mode 或逐项填写 129 条豁免。
|
||||
离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
|
||||
生产启动/发布脚本和 Gate 自身会展开到预定义影响域;未映射运行时代码直接失败。
|
||||
`PAGE-01`/`PAGE-02`/`DATA-01`–`DATA-04` 只在页面交付或 Page Data 产品代码变更时选中。
|
||||
同一 artifact 的 impact 报告可续跑已通过 suite;失败项与真 LLM suite 在新 commit 上重跑。
|
||||
发布脚本在有效报告缺失或过期时自动执行该入口,并在 Gitea CI `success` 时跳过与 CI 重复的
|
||||
本地 npm test/verify。离线 `--dry-run` 不连接 103;如需模拟风险分层,可设置
|
||||
`MEMIND_RELEASE_BASE_COMMIT=<known-stable-sha>`;未提供有效基线时直接阻断。
|
||||
|
||||
2026-07-26 本地补齐验证中,历史完整报告为 180/187 通过;`REL-01` 因当前仍在功能
|
||||
|
||||
+25
-5
@@ -43,17 +43,37 @@ npm run check:mindspace-public-links
|
||||
|
||||
6. `public/*.html` 里的**下载/附件**相对链接(如 `report.docx`)必须在同目录真实存在。发版脚本默认只查这类链接(`--downloads-only`);全量资源检查用 `npm run check:mindspace-public-links:all`。失败会阻断发版;确知要带着已知坏链上线时,才可临时 `ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES=1`。
|
||||
|
||||
## 2. Portal runtime 发布唯一流程
|
||||
## 2. Portal runtime 发布流程
|
||||
|
||||
生产发布守门员、Core + Impact Gate report 和本次明确人工批准全部满足后,首次生产动作只使用:
|
||||
103 整包替换提供两种模式:
|
||||
|
||||
### 2.1 标准发布(默认)
|
||||
|
||||
生产发布守门员、Core + Impact Gate report、103 灰度晋升证据和本次明确人工批准全部满足后,首次生产动作使用灰度:
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-canary-prod.sh --yes
|
||||
```
|
||||
|
||||
生产 `103` 禁止使用 `--skip-tests` 或任何范围绕过变量。整包替换脚本
|
||||
`release-portal-runtime-prod.sh` 只用于同一候选完成灰度验收后的全量晋升,在晋升证据
|
||||
校验完成前仍会硬阻断。本文历史记录中的旧绕过命令只用于事故复盘,不构成当前发布授权。
|
||||
灰度验收通过后,整包晋升:
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-runtime-prod.sh --yes
|
||||
```
|
||||
|
||||
标准发布禁止 `--skip-tests` 或任何范围绕过变量。
|
||||
|
||||
### 2.2 快速发布
|
||||
|
||||
变更已在本地/CI/Gate 充分验证、需尽快整包上线时使用:
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-fast-prod.sh --yes
|
||||
```
|
||||
|
||||
快速发布跳过灰度晋升证据、完整 Gate 重跑和 `publish-guards:full`;若当前 commit+artifact 已有有效 Gate report 则复用,否则只跑最小本地 smoke。仍执行 `check-release-ready`、103 备份、goosed 预检、8081 健康检查与明确人工批准。manifest 记录 `release_mode=fast`。
|
||||
|
||||
快速发布不替代发布前测试(§1.5);禁止用 `--skip-tests` 代替未做的验证。
|
||||
|
||||
这个脚本必须完成这些动作:
|
||||
|
||||
|
||||
@@ -82,12 +82,12 @@ test('Gate, artifact, candidate routing and rollback share one compatibility con
|
||||
assert.match(stableRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING="\$\{MEMIND_DEEPSEEK_DISABLE_THINKING:-1\}"/);
|
||||
assert.match(stableRunner, /export MEMIND_GOOSED_HOST_GATEWAY="\$\{MEMIND_GOOSED_HOST_GATEWAY:-host\.docker\.internal\}"/);
|
||||
assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/);
|
||||
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
||||
assert.match(candidateRunner, /MEMIND_CANARY_GOOSED_URL/);
|
||||
assert.match(compatRunner, /source "\$\{STABLE_ROOT\}\/\.env"/);
|
||||
assert.match(compatRunner, /export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1/);
|
||||
assert.match(canaryRelease, /run-deepseek-compat-proxy-candidate\.sh/);
|
||||
assert.match(canaryRelease, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
|
||||
assert.match(canaryRelease, /host\.docker\.internal:\$\{DEEPSEEK_COMPAT_PORT\}\/health/);
|
||||
assert.match(canaryRelease, /http:\/\/127\.0\.0\.1:\$\{DEEPSEEK_COMPAT_PORT\}\/health/);
|
||||
assert.match(canaryRelease, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(canaryRollback, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
});
|
||||
|
||||
+34
-3
@@ -17,6 +17,25 @@ export const CORE_SCENARIO_IDS = Object.freeze([
|
||||
'COMP-09',
|
||||
]);
|
||||
|
||||
// Live LLM product scenarios. Dependency closure from db.mjs / shared infra
|
||||
// still selects the DATA/PAGE deterministic suites; these four-to-six cases
|
||||
// only run when page-delivery or Page Data product code actually changed.
|
||||
export const LIVE_LLM_SCENARIO_IDS = Object.freeze([
|
||||
'PAGE-01',
|
||||
'PAGE-02',
|
||||
'DATA-01',
|
||||
'DATA-02',
|
||||
'DATA-03',
|
||||
'DATA-04',
|
||||
]);
|
||||
|
||||
const LIVE_LLM_PATH_PATTERNS = Object.freeze([
|
||||
/(?:^|\/)page-data-[^/]+\.mjs$/i,
|
||||
/(?:^|\/)mindspace-public-finish-sync\.mjs$/i,
|
||||
/(?:^|\/)mindspace-page-data[^/]*\.mjs$/i,
|
||||
/^scripts\/run-release-gate-page(?:-data)?-scenarios\.mjs$/i,
|
||||
]);
|
||||
|
||||
const CRITICAL_IMPACT_RULES = Object.freeze([
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'UI'],
|
||||
@@ -28,7 +47,7 @@ const CRITICAL_IMPACT_RULES = Object.freeze([
|
||||
},
|
||||
{
|
||||
groups: ['CFG', 'REL'],
|
||||
pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|run-release-gate|verify-release-gate|verify-canary-))/i,
|
||||
pattern: /^(?:release-gate\/|scripts\/(?:build-portal-runtime|check-release-ready|release-|rollback-portal-canary-prod|run-release-gate|verify-release-gate|verify-canary-))/i,
|
||||
},
|
||||
{
|
||||
groups: ['AGENT', 'CFG', 'REL'],
|
||||
@@ -58,6 +77,7 @@ const NON_RUNTIME_PATHS = Object.freeze([
|
||||
/^\.gitea\/workflows\//i,
|
||||
/^\.runtime\//i,
|
||||
/^docs\//i,
|
||||
/^scenarios\//i,
|
||||
/^\.cursor\//i,
|
||||
/^\.codex\//i,
|
||||
/^scripts\/dev(?:-|\.|\/)/i,
|
||||
@@ -79,7 +99,7 @@ const IMPACT_RULES = Object.freeze([
|
||||
{ groups: ['SCHED'], pattern: /(?:schedule|scheduler|reminder|cron)/i },
|
||||
{ groups: ['SEARCH'], pattern: /(?:search|weather|market|news-provider)/i },
|
||||
{ groups: ['XLS'], pattern: /(?:excel|xlsx|spreadsheet)/i },
|
||||
{ groups: ['IMGPG'], pattern: /(?:image|thumbnail|cover|imgproxy)/i },
|
||||
{ groups: ['IMGPG'], pattern: /(?:image-to-page|image-generation|imgproxy|thumbnails?|user-image-url|plaza-cover)/i },
|
||||
{ groups: ['FILE'], pattern: /(?:file|attachment|upload|document|pdf|docx|csv)/i },
|
||||
{ groups: ['MS'], pattern: /mindspace/i },
|
||||
{ groups: ['PAGE'], pattern: /(?:public-(?:page|finish)|published-page|publication|page-delivery|mindspace-public)/i },
|
||||
@@ -114,6 +134,13 @@ function matchesAny(patterns, relativePath) {
|
||||
return patterns.some((pattern) => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
export function pathTriggersLiveLlmScenarios(changedPaths) {
|
||||
return normalizePaths(changedPaths).some((relativePath) => (
|
||||
!matchesAny(NON_RUNTIME_PATHS, relativePath)
|
||||
&& LIVE_LLM_PATH_PATTERNS.some((pattern) => pattern.test(relativePath))
|
||||
));
|
||||
}
|
||||
|
||||
function closeGroupDependencies(initialGroups) {
|
||||
const groups = new Set(initialGroups);
|
||||
const pending = [...groups];
|
||||
@@ -172,10 +199,13 @@ export function selectImpactScenarios({
|
||||
}
|
||||
const impactGroups = closeGroupDependencies(directGroups);
|
||||
const strategy = impactGroups.length > 0 ? 'impact' : 'core';
|
||||
const includeLiveLlm = pathTriggersLiveLlmScenarios(normalizedPaths);
|
||||
const selected = new Set(CORE_SCENARIO_IDS);
|
||||
|
||||
for (const scenario of catalog) {
|
||||
if (impactGroups.includes(scenario.group)) selected.add(scenario.id);
|
||||
if (!impactGroups.includes(scenario.group)) continue;
|
||||
if (!includeLiveLlm && LIVE_LLM_SCENARIO_IDS.includes(scenario.id)) continue;
|
||||
selected.add(scenario.id);
|
||||
}
|
||||
|
||||
const selectedIds = catalog
|
||||
@@ -193,5 +223,6 @@ export function selectImpactScenarios({
|
||||
full_gate_reasons: [],
|
||||
selected_ids: selectedIds,
|
||||
selected_total: selectedIds.length,
|
||||
live_llm_selected: includeLiveLlm,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ test('critical paths expand mapped domains and unmapped paths block release', as
|
||||
critical.impact_groups,
|
||||
['AGENT', 'AUTH', 'CFG', 'CHAT', 'DATA', 'FILE', 'MS', 'PAGE'],
|
||||
);
|
||||
assert.equal(critical.live_llm_selected, false);
|
||||
assert.equal(critical.selected_ids.includes('DATA-01'), false);
|
||||
assert.equal(critical.selected_ids.includes('PAGE-01'), false);
|
||||
assert.equal(critical.selected_ids.includes('DATA-06'), true);
|
||||
assert.deepEqual(critical.full_gate_reasons, []);
|
||||
|
||||
assert.throws(
|
||||
@@ -73,6 +77,49 @@ test('critical paths expand mapped domains and unmapped paths block release', as
|
||||
);
|
||||
});
|
||||
|
||||
test('chat image turn-scope does not expand the image-to-page live domain', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['chat-image-turn-scope.mjs', 'wechat-mp.mjs'],
|
||||
});
|
||||
assert.equal(selection.impact_groups.includes('IMGPG'), false);
|
||||
assert.equal(selection.selected_ids.includes('PAGE-01'), false);
|
||||
assert.equal(selection.selected_ids.includes('DATA-01'), false);
|
||||
assert.equal(selection.selected_ids.includes('WX-01'), true);
|
||||
assert.equal(selection.selected_ids.includes('CHAT-01'), true);
|
||||
});
|
||||
|
||||
test('Page Data product code still selects live LLM DATA scenarios', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['page-data-routes.mjs'],
|
||||
});
|
||||
assert.equal(selection.live_llm_selected, true);
|
||||
assert.equal(selection.selected_ids.includes('DATA-01'), true);
|
||||
assert.equal(selection.selected_ids.includes('DATA-06'), true);
|
||||
});
|
||||
|
||||
test('image-generation files still map to IMGPG', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['mindspace-image-generation.mjs'],
|
||||
});
|
||||
assert.equal(selection.impact_groups.includes('IMGPG'), true);
|
||||
});
|
||||
|
||||
test('scenario fixtures are non-runtime and do not block mapping', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
catalog,
|
||||
changedPaths: ['scenarios/ai-usage-survey.json', 'docs/branch-disposition.md'],
|
||||
});
|
||||
assert.equal(selection.strategy, 'core');
|
||||
assert.equal(selection.live_llm_selected, false);
|
||||
});
|
||||
|
||||
test('release policy changes use mapped REL and CFG domains without selecting the catalog', async () => {
|
||||
const catalog = await loadScenarioCatalog();
|
||||
const selection = selectImpactScenarios({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export const GATE_SUITE_PORTS = Object.freeze({
|
||||
'runtime-sanitized-data-upgrade': 19085,
|
||||
'page-data-product-scenarios': 19086,
|
||||
'page-content-delivery-scenarios': 19087,
|
||||
'runtime-production-homolog-cold-start': 19081,
|
||||
});
|
||||
|
||||
const DOCKER_SOCKET_CANDIDATES = [
|
||||
process.env.DOCKER_HOST?.replace(/^unix:\/\//, ''),
|
||||
`${process.env.HOME ?? ''}/.docker/run/docker.sock`,
|
||||
'/var/run/docker.sock',
|
||||
].filter(Boolean);
|
||||
|
||||
export function dockerSocketExists() {
|
||||
return DOCKER_SOCKET_CANDIDATES.some((socketPath) => {
|
||||
try {
|
||||
return fs.existsSync(socketPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function assertDockerDaemonAvailable() {
|
||||
if (!dockerSocketExists()) {
|
||||
throw new Error(
|
||||
'Release gate REL-11 needs Docker; start Docker Desktop and retry. Missing docker.sock.',
|
||||
);
|
||||
}
|
||||
const result = spawnSync('docker', ['info'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`Release gate REL-11 needs a running Docker daemon: ${(result.stderr || result.error?.message || 'docker info failed').trim().slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLoopbackPortAvailable(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', (error) => {
|
||||
if (error?.code === 'EADDRINUSE') {
|
||||
reject(new Error(
|
||||
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) reject(closeError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
export async function preflightImpactSuites(suites) {
|
||||
const ports = [...new Set(
|
||||
suites
|
||||
.map((suite) => GATE_SUITE_PORTS[suite.id])
|
||||
.filter((port) => Number.isInteger(port)),
|
||||
)].sort((left, right) => left - right);
|
||||
for (const port of ports) {
|
||||
await assertLoopbackPortAvailable(port);
|
||||
}
|
||||
if (suites.some((suite) => suite.id === 'runtime-linux-dependency-closure')) {
|
||||
assertDockerDaemonAvailable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test from 'node:test';
|
||||
|
||||
import { GATE_SUITE_PORTS, assertLoopbackPortAvailable } from './preflight.mjs';
|
||||
|
||||
test('gate suites pin isolated loopback ports', () => {
|
||||
assert.equal(GATE_SUITE_PORTS['runtime-sanitized-data-upgrade'], 19085);
|
||||
assert.equal(GATE_SUITE_PORTS['page-data-product-scenarios'], 19086);
|
||||
assert.equal(GATE_SUITE_PORTS['page-content-delivery-scenarios'], 19087);
|
||||
});
|
||||
|
||||
test('assertLoopbackPortAvailable rejects an occupied port', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address();
|
||||
await assert.rejects(
|
||||
() => assertLoopbackPortAvailable(port),
|
||||
/already in use/,
|
||||
);
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await assertLoopbackPortAvailable(port);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ test('production release verifies gate report before 103 preflight and upload',
|
||||
assert.ok(gateIndex > 0, 'missing gate verifier');
|
||||
assert.ok(preflightIndex > gateIndex, '103 preflight must run after gate verification');
|
||||
assert.ok(uploadIndex > preflightIndex, 'upload must run after preflight');
|
||||
assert.match(source, /生产发布守门员禁止 --skip-tests/);
|
||||
assert.match(source, /标准发布禁止 --skip-tests/);
|
||||
assert.match(source, /禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS/);
|
||||
});
|
||||
|
||||
@@ -47,11 +47,11 @@ test('production stable release verifies canary promotion evidence before gate c
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const promotionIndex = source.indexOf('verify-canary-promotion-evidence.mjs');
|
||||
const gateIndex = source.indexOf('verify-release-gate-report.mjs');
|
||||
const promotionIndex = source.indexOf('say "验证 103 灰度晋升证据"');
|
||||
const gateCallIndex = source.indexOf('say "验证与当前 main 和 runtime artifact 绑定的 Gate report"');
|
||||
const impactIndex = source.indexOf('run-release-gate-impact.mjs');
|
||||
assert.ok(promotionIndex > 0, 'missing canary promotion evidence verifier');
|
||||
assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence');
|
||||
assert.ok(promotionIndex > 0, 'missing canary promotion evidence step');
|
||||
assert.ok(gateCallIndex > promotionIndex, 'gate verification must follow promotion evidence in standard flow');
|
||||
assert.ok(impactIndex > 0, 'missing risk-based impact gate fallback');
|
||||
assert.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/);
|
||||
assert.match(source, /read_agent_run_status_json/);
|
||||
@@ -125,10 +125,40 @@ test('production release rejects --skip-tests before repository or network prefl
|
||||
{ cwd: ROOT, encoding: 'utf8' },
|
||||
);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /禁止 --skip-tests/);
|
||||
assert.match(result.stderr, /标准发布禁止 --skip-tests/);
|
||||
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/);
|
||||
});
|
||||
|
||||
test('fast production release allows --skip-tests but still requires check-release-ready', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /ALLOW_MAIN_RELEASE=1 bash "\$\{ROOT\}\/scripts\/check-release-ready\.sh"/);
|
||||
assert.match(source, /快速发布:跳过额外本地测试/);
|
||||
const skipTestsBlockEnd = source.indexOf('fi\n\nif [[ "${DRY_RUN}" -eq 0 ]]; then');
|
||||
const checkReleaseIdx = source.indexOf('check-release-ready.sh');
|
||||
assert.ok(skipTestsBlockEnd >= 0 && checkReleaseIdx > skipTestsBlockEnd);
|
||||
});
|
||||
|
||||
test('fast release wrapper delegates to runtime prod script with --mode fast', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'release-portal-fast-prod.sh'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /release-portal-runtime-prod\.sh.*--mode fast/);
|
||||
});
|
||||
|
||||
test('fast production release skips canary promotion evidence in script', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /快速发布:跳过 103 灰度晋升证据/);
|
||||
assert.match(source, /verify-canary-promotion-evidence\.mjs/);
|
||||
assert.match(source, /release_mode=\$\{RELEASE_MODE\}/);
|
||||
});
|
||||
|
||||
test('production canary verifies the exact Gate artifact before any 103 preflight or upload', async () => {
|
||||
const source = await fs.readFile(CANARY_RELEASE, 'utf8');
|
||||
const gateIndex = source.indexOf('verify-release-gate-report.mjs');
|
||||
@@ -143,6 +173,10 @@ test('production canary verifies the exact Gate artifact before any 103 prefligh
|
||||
assert.match(source, /run-release-gate-impact\.mjs/);
|
||||
assert.match(source, /MEMIND_RELEASE_BASE_COMMIT/);
|
||||
assert.match(source, /-z "\$\{DEPLOYED_SHA\}".*"\$\{DRY_RUN\}" -ne 1/);
|
||||
assert.match(source, /resolve-release-ci-status\.mjs/);
|
||||
assert.match(source, /skipping duplicate local npm test\/verify/);
|
||||
const ciSkipIndex = source.indexOf('resolve-release-ci-status.mjs');
|
||||
assert.ok(ciSkipIndex > 0 && ciSkipIndex < gateIndex, 'CI reuse must happen before Gate verification');
|
||||
});
|
||||
|
||||
test('production canary keeps stable 8081 live and switches only after verified backups and fallback', async () => {
|
||||
@@ -150,11 +184,12 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
const fullBackup = source.indexOf('Create and verify the full stable backup');
|
||||
const persistBackup = source.indexOf('Create and verify the persisted-data backup');
|
||||
const edgeBackup = source.indexOf('Create and verify the active 105 nginx routing backup');
|
||||
const goosedStart = source.indexOf('Start an isolated goosed candidate on 18015');
|
||||
const goosedStart = source.indexOf('Use the native goosed pool 18006-18014');
|
||||
const candidateStart = source.indexOf('Start the passive candidate Portal on 18081');
|
||||
const deepseekCompatStart = source.indexOf(
|
||||
'Start the DeepSeek tool-round compatibility proxy on 18036',
|
||||
);
|
||||
assert.match(source, /Reuse the already healthy DeepSeek compatibility proxy/);
|
||||
const proxyStart = source.indexOf('Start the fail-closed identity router on 18082');
|
||||
const tunnelStart = source.indexOf('Start the isolated 105 reverse tunnel on 19082');
|
||||
const edgeSwitch = source.indexOf(
|
||||
@@ -178,6 +213,8 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.match(source, /CANARY_PROXY_PORT=18082/);
|
||||
assert.match(source, /CANARY_TUNNEL_REMOTE_PORT=19082/);
|
||||
assert.match(source, /DEEPSEEK_COMPAT_PORT=18036/);
|
||||
assert.match(source, /Use the native goosed pool 18006-18014/);
|
||||
assert.doesNotMatch(source, /docker inspect goosed-prod-1/);
|
||||
assert.match(source, /deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(source, /run-deepseek-compat-proxy-candidate\.sh/);
|
||||
assert.match(source, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
|
||||
@@ -216,7 +253,7 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.doesNotMatch(source, /bootout.*cn\.tkmind\.memind-portal/);
|
||||
});
|
||||
|
||||
test('candidate runner overrides stable host MCP paths with container-visible paths', async (t) => {
|
||||
test('candidate runner inherits native goosed MCP paths from the stable root', async (t) => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-canary-runner-'));
|
||||
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
|
||||
|
||||
@@ -284,14 +321,14 @@ test('candidate runner overrides stable host MCP paths with container-visible pa
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, new RegExp(`^cwd=${ROOT}$`, 'm'));
|
||||
assert.match(result.stdout, /^mcp_node=\/usr\/local\/bin\/node$/m);
|
||||
assert.match(result.stdout, /^mcp_node=\/opt\/homebrew\/opt\/node@24\/bin\/node$/m);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/^mcp_server=\/opt\/portal\/mindspace-sandbox-mcp\.mjs$/m,
|
||||
/^mcp_server=\/Users\/john\/Project\/Memind\/mindspace-sandbox-mcp\.mjs$/m,
|
||||
);
|
||||
assert.match(result.stdout, /^deepseek_disable=1$/m);
|
||||
assert.match(result.stdout, /^deepseek_port=18036$/m);
|
||||
assert.match(result.stdout, /^deepseek_gateway=host\.docker\.internal$/m);
|
||||
assert.match(result.stdout, /^deepseek_gateway=wrong\.invalid$/m);
|
||||
assert.match(result.stdout, /^deepseek_base=unset$/m);
|
||||
assert.match(result.stdout, /^deepseek_host=unset$/m);
|
||||
assert.match(result.stdout, /^page_data_review=1$/m);
|
||||
@@ -310,7 +347,7 @@ test('canary rollback stops the DeepSeek compatibility process with the other ca
|
||||
/DEEPSEEK_COMPAT_LABEL="cn\.tkmind\.memind-deepseek-compat-candidate"/,
|
||||
);
|
||||
assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(source, /docker rm -f goosed-prod-canary/);
|
||||
assert.doesNotMatch(source, /docker rm -f goosed-prod-canary/);
|
||||
});
|
||||
|
||||
test('DeepSeek compatibility runner preserves stable upstream config but enforces candidate controls', async (t) => {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const REPOSITORY_CHECK_IDS = new Set(['REL-01', 'REL-02', 'REL-04']);
|
||||
|
||||
export function commandFilesForSuite(suite) {
|
||||
return (suite.command ?? [])
|
||||
.slice(1)
|
||||
.filter((arg) => typeof arg === 'string' && !arg.startsWith('-'))
|
||||
.map((arg) => arg.replaceAll('\\', '/'));
|
||||
}
|
||||
|
||||
export function suiteInvalidatedByChanges(suite, changedPaths) {
|
||||
const normalized = new Set((changedPaths ?? []).map((item) => String(item).replaceAll('\\', '/')));
|
||||
if (normalized.has('release-gate/coverage.mjs')) return true;
|
||||
return commandFilesForSuite(suite).some((filePath) => normalized.has(filePath));
|
||||
}
|
||||
|
||||
export function previousScenarioMap(report) {
|
||||
return new Map((report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
|
||||
}
|
||||
|
||||
export function shouldRerunImpactSuite({
|
||||
suite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256,
|
||||
commitSha,
|
||||
changedPathsSincePrevious = [],
|
||||
}) {
|
||||
const selectedInSuite = suite.scenarios.filter((scenarioId) => selectedIds.has(scenarioId));
|
||||
if (selectedInSuite.length === 0) return false;
|
||||
if (!previousReport || previousReport.artifact_sha256 !== artifactSha256) return true;
|
||||
if (suiteInvalidatedByChanges(suite, changedPathsSincePrevious)) return true;
|
||||
if (suite.mode === 'scenarios' && previousReport.commit_sha !== commitSha) return true;
|
||||
|
||||
const previous = previousScenarioMap(previousReport);
|
||||
return selectedInSuite.some((scenarioId) => previous.get(scenarioId)?.status !== 'passed');
|
||||
}
|
||||
|
||||
export function carryForwardScenario(previousScenario) {
|
||||
return {
|
||||
...previousScenario,
|
||||
evidence: [
|
||||
...(previousScenario.evidence ?? []),
|
||||
'carried_forward=true',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function findImpactResumeReport({
|
||||
reportRoot,
|
||||
artifactSha256,
|
||||
commitSha,
|
||||
}) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(reportRoot, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name === 'local') continue;
|
||||
const reportPath = path.join(reportRoot, entry.name, 'report.json');
|
||||
try {
|
||||
const report = JSON.parse(await fs.readFile(reportPath, 'utf8'));
|
||||
if (report?.mode !== 'impact') continue;
|
||||
if (report.artifact_sha256 !== artifactSha256) continue;
|
||||
if (!Array.isArray(report.scenarios)) continue;
|
||||
candidates.push({ report, reportPath, commitSha: report.commit_sha });
|
||||
} catch {
|
||||
// ignore unreadable reports
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const sameCommit = candidates.find((candidate) => candidate.commitSha === commitSha);
|
||||
if (sameCommit) return sameCommit;
|
||||
candidates.sort((left, right) => (
|
||||
Date.parse(right.report.completed_at ?? 0) - Date.parse(left.report.completed_at ?? 0)
|
||||
));
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
export { REPOSITORY_CHECK_IDS };
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
carryForwardScenario,
|
||||
commandFilesForSuite,
|
||||
shouldRerunImpactSuite,
|
||||
suiteInvalidatedByChanges,
|
||||
} from './resume.mjs';
|
||||
|
||||
const chatSuite = {
|
||||
id: 'chat-routing-contract',
|
||||
mode: 'deterministic',
|
||||
scenarios: ['CHAT-01', 'CHAT-02'],
|
||||
command: [process.execPath, '--test', 'chat-router.test.mjs'],
|
||||
};
|
||||
|
||||
const liveSuite = {
|
||||
id: 'page-data-product-scenarios',
|
||||
mode: 'scenarios',
|
||||
scenarios: ['DATA-01', 'DATA-02'],
|
||||
command: [process.execPath, 'scripts/run-release-gate-page-data-scenarios.mjs'],
|
||||
};
|
||||
|
||||
const previousReport = {
|
||||
mode: 'impact',
|
||||
commit_sha: 'a'.repeat(40),
|
||||
artifact_sha256: 'b'.repeat(64),
|
||||
scenarios: [
|
||||
{ id: 'CHAT-01', status: 'passed', evidence: ['suite=chat-routing-contract'] },
|
||||
{ id: 'CHAT-02', status: 'passed', evidence: ['suite=chat-routing-contract'] },
|
||||
{ id: 'DATA-01', status: 'failed', evidence: ['suite=page-data-product-scenarios'] },
|
||||
{ id: 'DATA-02', status: 'passed', evidence: ['suite=page-data-product-scenarios'] },
|
||||
],
|
||||
};
|
||||
|
||||
test('commandFilesForSuite skips node and flags', () => {
|
||||
assert.deepEqual(
|
||||
commandFilesForSuite({ command: [process.execPath, '--test', 'chat-router.test.mjs'] }),
|
||||
['chat-router.test.mjs'],
|
||||
);
|
||||
});
|
||||
|
||||
test('same commit and artifact skips passed suites and reruns failed live suites', () => {
|
||||
const selectedIds = new Set(['CHAT-01', 'CHAT-02', 'DATA-01', 'DATA-02']);
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: chatSuite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256: previousReport.artifact_sha256,
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), false);
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: liveSuite,
|
||||
selectedIds,
|
||||
previousReport,
|
||||
artifactSha256: previousReport.artifact_sha256,
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('artifact mismatch forces every selected suite to rerun', () => {
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: chatSuite,
|
||||
selectedIds: new Set(['CHAT-01', 'CHAT-02']),
|
||||
previousReport,
|
||||
artifactSha256: 'c'.repeat(64),
|
||||
commitSha: previousReport.commit_sha,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('live suites rerun on a new commit even when previous cases passed', () => {
|
||||
const passedLive = {
|
||||
...previousReport,
|
||||
scenarios: [
|
||||
{ id: 'DATA-01', status: 'passed' },
|
||||
{ id: 'DATA-02', status: 'passed' },
|
||||
],
|
||||
};
|
||||
assert.equal(shouldRerunImpactSuite({
|
||||
suite: liveSuite,
|
||||
selectedIds: new Set(['DATA-01', 'DATA-02']),
|
||||
previousReport: passedLive,
|
||||
artifactSha256: passedLive.artifact_sha256,
|
||||
commitSha: 'd'.repeat(40),
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('changing a suite command file invalidates that suite', () => {
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['chat-router.test.mjs']), true);
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['wechat-mp.mjs']), false);
|
||||
assert.equal(suiteInvalidatedByChanges(chatSuite, ['release-gate/coverage.mjs']), true);
|
||||
assert.equal(
|
||||
suiteInvalidatedByChanges(liveSuite, ['scripts/run-release-gate-runtime-container.mjs']),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
suiteInvalidatedByChanges(liveSuite, ['scripts/run-release-gate-page-data-scenarios.mjs']),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('carryForwardScenario keeps prior evidence and marks reuse', () => {
|
||||
const carried = carryForwardScenario({ id: 'CHAT-01', status: 'passed', evidence: ['old'] });
|
||||
assert.equal(carried.status, 'passed');
|
||||
assert.deepEqual(carried.evidence, ['old', 'carried_forward=true']);
|
||||
});
|
||||
+54
-5
@@ -6,7 +6,14 @@ import { assertPortalRuntimePath, hashArtifact, inspectPortalRuntime } from './a
|
||||
import { loadScenarioCatalog } from './catalog.mjs';
|
||||
import { AUTOMATION_SUITES, validateAutomationSuites } from './coverage.mjs';
|
||||
import { selectImpactScenarios } from './impact.mjs';
|
||||
import { preflightImpactSuites } from './preflight.mjs';
|
||||
import { loadActiveRegressionCorpus } from './regression-corpus.mjs';
|
||||
import {
|
||||
REPOSITORY_CHECK_IDS,
|
||||
carryForwardScenario,
|
||||
findImpactResumeReport,
|
||||
shouldRerunImpactSuite,
|
||||
} from './resume.mjs';
|
||||
import {
|
||||
buildIncrementalReport,
|
||||
findCarryForwardBaseline,
|
||||
@@ -419,24 +426,61 @@ export async function executeImpactReleaseGate(options) {
|
||||
const suites = AUTOMATION_SUITES.filter(
|
||||
(suite) => suite.scenarios.some((scenarioId) => selectedIds.has(scenarioId)),
|
||||
);
|
||||
const resume = await findImpactResumeReport({
|
||||
reportRoot: options.reportRoot,
|
||||
artifactSha256: artifact.sha256,
|
||||
commitSha,
|
||||
});
|
||||
let changedPathsSincePrevious = [];
|
||||
if (resume?.commitSha && resume.commitSha !== commitSha) {
|
||||
try {
|
||||
changedPathsSincePrevious = await listChangedPathsBetween(resume.commitSha, commitSha);
|
||||
} catch {
|
||||
changedPathsSincePrevious = ['release-gate/coverage.mjs'];
|
||||
}
|
||||
}
|
||||
const suitesToRun = suites.filter((suite) => shouldRerunImpactSuite({
|
||||
suite,
|
||||
selectedIds,
|
||||
previousReport: resume?.report ?? null,
|
||||
artifactSha256: artifact.sha256,
|
||||
commitSha,
|
||||
changedPathsSincePrevious,
|
||||
}));
|
||||
const skippedSuites = suites.filter((suite) => !suitesToRun.includes(suite));
|
||||
const previousById = new Map((resume?.report?.scenarios ?? []).map((scenario) => [scenario.id, scenario]));
|
||||
for (const suite of skippedSuites) {
|
||||
for (const scenarioId of suite.scenarios) {
|
||||
if (!selectedIds.has(scenarioId) || REPOSITORY_CHECK_IDS.has(scenarioId)) continue;
|
||||
const previous = previousById.get(scenarioId);
|
||||
if (!previous) continue;
|
||||
const current = byId.get(scenarioId);
|
||||
Object.assign(current, carryForwardScenario(previous));
|
||||
}
|
||||
}
|
||||
|
||||
if (suitesToRun.length > 0) {
|
||||
await preflightImpactSuites(suitesToRun);
|
||||
}
|
||||
const executions = await runSuitesWithConcurrency(
|
||||
suites,
|
||||
suitesToRun,
|
||||
options.suiteConcurrency,
|
||||
(suite) => runSuite(suite, outputDir, options.timeoutMs),
|
||||
);
|
||||
for (let index = 0; index < suites.length; index += 1) {
|
||||
const suite = suites[index];
|
||||
for (let index = 0; index < suitesToRun.length; index += 1) {
|
||||
const suite = suitesToRun[index];
|
||||
const execution = executions[index];
|
||||
for (const scenarioId of suite.scenarios) {
|
||||
if (!selectedIds.has(scenarioId)) continue;
|
||||
const scenario = byId.get(scenarioId);
|
||||
scenario.status = execution.code === 0 && !execution.timedOut ? 'passed' : 'failed';
|
||||
scenario.reason = scenario.status === 'passed' ? null : 'automation_suite_failed';
|
||||
scenario.evidence.push(
|
||||
scenario.evidence = [
|
||||
`suite=${suite.id}`,
|
||||
`log=${execution.logPath}`,
|
||||
...suite.cases[scenarioId].map((assertedCase) => `asserted_case=${assertedCase}`),
|
||||
);
|
||||
'carried_forward=false',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,6 +501,11 @@ export async function executeImpactReleaseGate(options) {
|
||||
startedAt,
|
||||
completedAt,
|
||||
});
|
||||
report.resume = {
|
||||
baseline_commit: resume?.commitSha ?? null,
|
||||
carried_suites: skippedSuites.map((suite) => suite.id),
|
||||
reran_suites: suitesToRun.map((suite) => suite.id),
|
||||
};
|
||||
await writeGateReport(report, outputDir);
|
||||
return { report, outputDir, selection };
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"expect": {
|
||||
"assistantMinChars": 80,
|
||||
"timeoutMs": 600000,
|
||||
"replyKeywords": ["方案", "确认"],
|
||||
"replyKeywords": ["问卷", "后台"],
|
||||
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -50,8 +50,8 @@ Deploys a candidate Portal beside the stable 103 runtime:
|
||||
stable Portal 127.0.0.1:8081
|
||||
canary router 127.0.0.1:18082
|
||||
candidate Portal 127.0.0.1:18081
|
||||
candidate goosed 127.0.0.1:18015
|
||||
DeepSeek compat 0.0.0.0:18036 (host/container only)
|
||||
native goosed 127.0.0.1:18006-18014
|
||||
DeepSeek compat 0.0.0.0:18036 (host only; reused if already healthy)
|
||||
|
||||
The stable runtime is not replaced. A dedicated reverse tunnel exposes the
|
||||
router only to 105 at 127.0.0.1:19082. The committed release workflow updates
|
||||
@@ -116,15 +116,19 @@ fi
|
||||
ALLOW_MAIN_RELEASE=1 bash "${ROOT}/scripts/check-release-ready.sh" --skip-fetch
|
||||
|
||||
say "Run release source guards"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:mindspace-page-sync-guards >/dev/null
|
||||
npm run verify:h5-session-patches >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
npm run check:mindspace-public-links >/dev/null
|
||||
)
|
||||
if node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "${FULL_SHA}"; then
|
||||
say "Gitea CI already succeeded for ${FULL_SHA}; skipping duplicate local npm test/verify"
|
||||
else
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:mindspace-page-sync-guards >/dev/null
|
||||
npm run verify:h5-session-patches >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
npm run check:mindspace-public-links >/dev/null
|
||||
)
|
||||
fi
|
||||
|
||||
required_runtime_paths=(
|
||||
server.mjs
|
||||
@@ -179,7 +183,7 @@ printf '%s %s\n' "${bundle_sha}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
|
||||
echo "git_branch=${branch}"
|
||||
echo "artifact_tree=.runtime/portal"
|
||||
echo "artifact_bundle_sha256=${bundle_sha}"
|
||||
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036"
|
||||
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-native:18006-18014,deepseek-compat:18036"
|
||||
echo "canary_usernames=${CANARY_USERNAMES}"
|
||||
echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}"
|
||||
echo "canary_wechat_page_data_aider_review_enabled=${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED}"
|
||||
@@ -198,14 +202,16 @@ say "Run 103 read-only preflight"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"test -d '${STABLE_DIR}' \
|
||||
&& test -f '${STABLE_DIR}/.env' \
|
||||
&& test -f '${GOOSED_DIR}/docker-compose.prod.yml' \
|
||||
&& test -x '${CANARY_AIDER_BIN}' \
|
||||
&& curl -fsS http://127.0.0.1:8081/api/status >/dev/null \
|
||||
&& ! lsof -nP -iTCP:${CANARY_PROXY_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& { ! lsof -nP -iTCP:${DEEPSEEK_COMPAT_PORT} -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
|| curl -fsS --max-time 5 http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health \
|
||||
| grep -q '"deepseekThinking":"disabled"'; } \
|
||||
&& ! lsof -nP -iTCP:18081 -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& ! lsof -nP -iTCP:18015 -sTCP:LISTEN >/dev/null 2>&1 \
|
||||
&& /opt/homebrew/bin/docker inspect goosed-prod-1 >/dev/null \
|
||||
&& for _p in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do \
|
||||
curl -kfsS --max-time 2 https://127.0.0.1:\${_p}/status | grep -qx ok; \
|
||||
done \
|
||||
&& test \"\$(df -Pk '${REMOTE_ROOT}' | awk 'NR==2 {print \$4}')\" -gt 10485760 \
|
||||
&& ssh -o BatchMode=yes -o ConnectTimeout=10 '${EDGE_HOST}' \
|
||||
\"test -f '${EDGE_MOBILE_CONFIG}' \
|
||||
@@ -333,8 +339,6 @@ PROXY_PLIST="${HOME}/Library/LaunchAgents/${CANARY_PROXY_LABEL}.plist"
|
||||
CANARY_TUNNEL_PLIST="${HOME}/Library/LaunchAgents/${CANARY_TUNNEL_LABEL}.plist"
|
||||
DEEPSEEK_COMPAT_PLIST="${HOME}/Library/LaunchAgents/${DEEPSEEK_COMPAT_LABEL}.plist"
|
||||
SECRET_FILE="${HOME}/.config/memind/canary-router.secret"
|
||||
DOCKER_BIN="/opt/homebrew/bin/docker"
|
||||
GOOSED_COMPOSE="${GOOSED_DIR}/docker-compose.prod.yml"
|
||||
EDGE_MOBILE_BACKUP="${EDGE_MOBILE_CONFIG}.before-canary-${RELEASE_ID}"
|
||||
EDGE_WECHAT_BACKUP="${EDGE_WECHAT_CONFIG}.before-canary-${RELEASE_ID}"
|
||||
|
||||
@@ -441,7 +445,6 @@ stop_candidate_services() {
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
|
||||
"${DOCKER_BIN}" rm -f goosed-prod-canary >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
rollback() {
|
||||
@@ -508,30 +511,15 @@ say "Stop an older canary without touching stable Portal 8081"
|
||||
restore_edge_to_stable
|
||||
stop_candidate_services
|
||||
|
||||
say "Start an isolated goosed candidate on 18015"
|
||||
(
|
||||
cd "${GOOSED_DIR}"
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
set +a
|
||||
export CANDIDATE_RUNTIME_DIR="${CANDIDATE_DIR}"
|
||||
"${DOCKER_BIN}" compose -p goosed-prod \
|
||||
-f "${GOOSED_COMPOSE}" \
|
||||
-f "${CANDIDATE_DIR}/scripts/goosed-canary.compose.yml" \
|
||||
up -d --no-deps goosed-canary
|
||||
)
|
||||
for _ in $(seq 1 60); do
|
||||
if [[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
say "Use the native goosed pool 18006-18014"
|
||||
for _p in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do
|
||||
[[ "$(curl -skS -m 5 "https://127.0.0.1:${_p}/status" 2>/dev/null || true)" == "ok" ]]
|
||||
done
|
||||
[[ "$(curl -skS -m 5 https://127.0.0.1:18015/status 2>/dev/null || true)" == "ok" ]]
|
||||
"${DOCKER_BIN}" exec goosed-prod-canary \
|
||||
sh -lc 'test -x /usr/local/bin/node && test -f /opt/portal/mindspace-sandbox-mcp.mjs'
|
||||
|
||||
say "Start the DeepSeek tool-round compatibility proxy on 18036"
|
||||
if deepseek_compat_healthy; then
|
||||
say "Reuse the already healthy DeepSeek compatibility proxy on ${DEEPSEEK_COMPAT_PORT}"
|
||||
else
|
||||
cat > "${DEEPSEEK_COMPAT_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">
|
||||
@@ -560,9 +548,7 @@ for _ in $(seq 1 30); do
|
||||
sleep 1
|
||||
done
|
||||
deepseek_compat_healthy
|
||||
"${DOCKER_BIN}" exec goosed-prod-canary \
|
||||
sh -lc "curl -fsS --max-time 5 http://host.docker.internal:${DEEPSEEK_COMPAT_PORT}/health \
|
||||
| grep -q '\"deepseekThinking\":\"disabled\"'"
|
||||
fi
|
||||
|
||||
say "Start the passive candidate Portal on 18081"
|
||||
cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
@@ -578,7 +564,6 @@ cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
|
||||
<key>MEMIND_CANARY_RELEASE_ID</key><string>${RELEASE_ID}</string>
|
||||
<key>MEMIND_CANARY_CANDIDATE_PORT</key><string>18081</string>
|
||||
<key>MEMIND_CANARY_GOOSED_URL</key><string>https://127.0.0.1:18015</string>
|
||||
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
|
||||
<key>MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED</key><string>${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED}</string>
|
||||
<key>MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS</key><string>${CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS}</string>
|
||||
@@ -809,7 +794,7 @@ printf 'stable_health=http://127.0.0.1:8081/api/status\n'
|
||||
printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PROXY_PORT}"
|
||||
printf 'edge_tunnel=http://127.0.0.1:%s/api/status\n' "${CANARY_TUNNEL_REMOTE_PORT}"
|
||||
printf 'candidate_health=http://127.0.0.1:18081/api/status\n'
|
||||
printf 'candidate_goosed=https://127.0.0.1:18015/status\n'
|
||||
printf 'candidate_goosed=https://127.0.0.1:18006-18014/status\n'
|
||||
printf 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
exec bash "${ROOT}/scripts/release-portal-runtime-prod.sh" --mode fast "$@"
|
||||
@@ -26,40 +26,97 @@ DRY_RUN=0
|
||||
SKIP_TESTS=0
|
||||
SKIP_BUILD=0
|
||||
AUTO_YES=0
|
||||
RELEASE_MODE="${MEMIND_RELEASE_MODE:-standard}"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${TMP_DIR}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
say() {
|
||||
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
bash scripts/release-portal-runtime-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes]
|
||||
bash scripts/release-portal-runtime-prod.sh [--mode standard|fast] [--dry-run] [--skip-tests] [--skip-build] [--yes]
|
||||
bash scripts/release-portal-fast-prod.sh [--dry-run] [--skip-build] [--yes]
|
||||
|
||||
说明:
|
||||
1. 本地构建 Portal 无源码 runtime artifact
|
||||
2. 上传到 103(固定公网地址)
|
||||
3. 103 备份当前 Memind 全目录 + 持久目录
|
||||
4. 停止旧 Portal 服务
|
||||
5. 用 runtime artifact 替换 live 目录,只继承持久目录
|
||||
6. 启动 Portal,保持旧端口 8081
|
||||
7. 健康检查通过后重启 m.tkmind.cn 反向隧道 (105:19081 -> 103:8081)
|
||||
8. 旧源码目录移入 archive,不再保留可运行 live 源码
|
||||
发布模式:
|
||||
standard(默认)
|
||||
完整标准发布:灰度晋升证据 + Core+Impact Gate + 完整 MindSpace 守卫 + 103 整包替换。
|
||||
标准流程应先跑 scripts/release-portal-canary-prod.sh,验收后再执行本脚本。
|
||||
|
||||
fast
|
||||
快速发布:跳过灰度晋升证据、完整 Gate 重跑和 publish-guards:full。
|
||||
仍执行 check-release-ready、runtime 构建/校验、103 备份与回滚、goosed 预检、8081 健康检查。
|
||||
若当前 commit+artifact 已有有效 Gate report 则直接复用;否则只跑最小本地 smoke + release-gate 单测。
|
||||
快速发布需要明确人工批准(--yes 或发布前交互确认);全站用户会立即命中新版本。
|
||||
|
||||
环境变量:
|
||||
MEMIND_RELEASE_MODE=standard|fast
|
||||
ALLOW_DIRECT_STABLE_RELEASE=1
|
||||
仅在用户明确批准按 7/23 前规则直接整包时使用:允许 --skip-tests,
|
||||
并跳过 canary 晋升证据与 Core+Impact Gate report。
|
||||
已废弃;等价于 --mode fast。仅保留兼容旧命令。
|
||||
EOF
|
||||
}
|
||||
|
||||
is_fast_release() {
|
||||
[[ "${RELEASE_MODE}" == "fast" ]]
|
||||
}
|
||||
|
||||
run_fast_release_guards() {
|
||||
say "快速发布:运行最小本地验证"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing|wechat' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
npm run test:release-gate:unit >/dev/null
|
||||
)
|
||||
}
|
||||
|
||||
verify_gate_for_release() {
|
||||
if is_fast_release; then
|
||||
if node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
|
||||
say "快速发布:复用当前 commit 的有效 Gate report"
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
return 0
|
||||
fi
|
||||
say "快速发布:无有效 Gate report,跳过 Core+Impact 重跑,改跑最小本地验证"
|
||||
run_fast_release_guards
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
|
||||
say "执行核心场景 + 变更影响域 Gate"
|
||||
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
|
||||
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
|
||||
DEPLOYED_SHA="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"grep -E '^git_head=' '${APP_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}")
|
||||
if [[ -n "${DEPLOYED_SHA}" ]]; then
|
||||
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
|
||||
fi
|
||||
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
|
||||
fi
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--skip-tests) SKIP_TESTS=1 ;;
|
||||
--skip-build) SKIP_BUILD=1 ;;
|
||||
--yes|-y) AUTO_YES=1 ;;
|
||||
--mode)
|
||||
RELEASE_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--skip-tests) SKIP_TESTS=1; shift ;;
|
||||
--skip-build) SKIP_BUILD=1; shift ;;
|
||||
--yes|-y) AUTO_YES=1; shift ;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -70,18 +127,26 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
say() {
|
||||
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
case "${RELEASE_MODE}" in
|
||||
standard|fast) ;;
|
||||
*)
|
||||
echo "未知发布模式: ${RELEASE_MODE}(允许 standard 或 fast)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "ALLOW_DIRECT_STABLE_RELEASE 已废弃,按 --mode fast 处理"
|
||||
RELEASE_MODE=fast
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_TESTS}" -eq 1 ]]; then
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "允许 --skip-tests(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则;测试须已在发布前手动跑过)"
|
||||
if is_fast_release; then
|
||||
say "快速发布:跳过额外本地测试(发布前须已自行验证)"
|
||||
else
|
||||
echo "生产发布守门员禁止 --skip-tests。" >&2
|
||||
echo "标准发布禁止 --skip-tests。" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -97,8 +162,8 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "跳过灰度晋升证据(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
|
||||
if is_fast_release; then
|
||||
say "快速发布:跳过 103 灰度晋升证据"
|
||||
else
|
||||
say "验证 103 灰度晋升证据"
|
||||
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
|
||||
@@ -153,13 +218,19 @@ say "本地预检查"
|
||||
check_release_scope
|
||||
|
||||
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||||
say "运行最小验证"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
if is_fast_release; then
|
||||
say "快速发布:本地验证由 Gate 复用检查或 verify_gate_for_release 统一执行"
|
||||
elif node "${ROOT}/scripts/resolve-release-ci-status.mjs" --commit "$(git -C "${ROOT}" rev-parse HEAD)"; then
|
||||
say "Gitea CI already succeeded; skipping duplicate local npm test/verify"
|
||||
else
|
||||
say "运行最小验证"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
|
||||
npm run verify:mindspace-publish-guards >/dev/null
|
||||
npm run verify:page-data >/dev/null
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||||
@@ -191,11 +262,15 @@ verify_runtime_artifact() {
|
||||
|
||||
verify_runtime_artifact
|
||||
|
||||
say "验证 MindSpace 发布与聊天 Finish 回归守卫"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm run verify:mindspace-publish-guards:full
|
||||
)
|
||||
if is_fast_release; then
|
||||
say "快速发布:跳过 verify:mindspace-publish-guards:full"
|
||||
else
|
||||
say "验证 MindSpace 发布与聊天 Finish 回归守卫"
|
||||
(
|
||||
cd "${ROOT}"
|
||||
npm run verify:mindspace-publish-guards:full
|
||||
)
|
||||
fi
|
||||
|
||||
verify_mindspace_public_links() {
|
||||
local target_root="${1:-${ROOT}/MindSpace}"
|
||||
@@ -349,27 +424,7 @@ REMOTE
|
||||
}
|
||||
|
||||
say "验证与当前 main 和 runtime artifact 绑定的 Gate report"
|
||||
if [[ "${ALLOW_DIRECT_STABLE_RELEASE:-0}" == "1" ]]; then
|
||||
say "跳过 Core+Impact Gate report(ALLOW_DIRECT_STABLE_RELEASE=1,按 7/23 前直接整包规则)"
|
||||
elif ! node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}" >/dev/null 2>&1; then
|
||||
say "执行核心场景 + 变更影响域 Gate"
|
||||
DEPLOYED_SHA="${MEMIND_RELEASE_BASE_COMMIT:-}"
|
||||
if [[ -z "${DEPLOYED_SHA}" && "${DRY_RUN}" -ne 1 ]]; then
|
||||
DEPLOYED_SHA="$(
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
"grep -E '^git_head=' '${APP_DIR}/.release-manifest.txt' 2>/dev/null | tail -1 | cut -d= -f2-" \
|
||||
2>/dev/null || true
|
||||
)"
|
||||
fi
|
||||
IMPACT_ARGS=(--artifact "${RUNTIME_ROOT}")
|
||||
if [[ -n "${DEPLOYED_SHA}" ]]; then
|
||||
IMPACT_ARGS+=(--deployed-commit "${DEPLOYED_SHA}")
|
||||
fi
|
||||
node "${ROOT}/scripts/run-release-gate-impact.mjs" "${IMPACT_ARGS[@]}"
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
else
|
||||
node "${ROOT}/scripts/verify-release-gate-report.mjs" --artifact "${RUNTIME_ROOT}"
|
||||
fi
|
||||
verify_gate_for_release
|
||||
|
||||
if [[ "${DRY_RUN}" -ne 1 ]]; then
|
||||
say "执行 103 只读预检"
|
||||
@@ -385,6 +440,11 @@ if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||
echo "发布编号: ${RELEASE_ID}"
|
||||
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
echo "说明: 此次只切换 test-memind Portal 到无源码 runtime,不包含 memindadm / memindplaza"
|
||||
if is_fast_release; then
|
||||
echo "模式: 快速发布(跳过灰度晋升证据与完整 Gate 重跑)"
|
||||
else
|
||||
echo "模式: 标准发布"
|
||||
fi
|
||||
read -r -p "确认继续发布到 103? [y/N] " confirm </dev/tty
|
||||
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
|
||||
fi
|
||||
@@ -396,6 +456,7 @@ say "生成发布清单"
|
||||
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 "release_mode=${RELEASE_MODE}"
|
||||
echo "artifact=.runtime/portal"
|
||||
echo "persisted_items=.env, MindSpace, data, users, .tailscale, public/plaza-covers, logs"
|
||||
} > "${MANIFEST_PATH}"
|
||||
|
||||
@@ -72,7 +72,6 @@ launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || tru
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_PROXY_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_CANDIDATE_LABEL}" >/dev/null 2>&1 || true
|
||||
launchctl bootout "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null 2>&1 || true
|
||||
/opt/homebrew/bin/docker rm -f goosed-prod-canary >/dev/null 2>&1 || true
|
||||
rm -f "${STABLE_DIR}/.release-drain"
|
||||
|
||||
curl -fsS http://127.0.0.1:8081/api/status >/dev/null
|
||||
|
||||
@@ -27,17 +27,18 @@ export H5_REMINDER_WORKER_ENABLED=0
|
||||
export H5_PORT="${MEMIND_CANARY_CANDIDATE_PORT:-18081}"
|
||||
export H5_HOST=127.0.0.1
|
||||
export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}"
|
||||
export TKMIND_API_TARGETS="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}"
|
||||
export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL:-https://127.0.0.1:18015}"
|
||||
# Extensions are spawned inside goosed-canary, where the candidate artifact is
|
||||
# mounted at /opt/portal. Never inherit host-only MCP paths from the stable .env.
|
||||
export GOOSED_MCP_NODE_PATH=/usr/local/bin/node
|
||||
export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
|
||||
if [[ -n "${MEMIND_CANARY_GOOSED_URL:-}" ]]; then
|
||||
export TKMIND_API_TARGETS="${MEMIND_CANARY_GOOSED_URL}"
|
||||
export TKMIND_API_TARGET="${MEMIND_CANARY_GOOSED_URL}"
|
||||
# Isolated Docker goosed mounts the candidate artifact at /opt/portal.
|
||||
export GOOSED_MCP_NODE_PATH=/usr/local/bin/node
|
||||
export GOOSED_MCP_SERVER_PATH=/opt/portal/mindspace-sandbox-mcp.mjs
|
||||
export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
|
||||
fi
|
||||
# DeepSeek V4 tool rounds must use the same compatibility contract exercised
|
||||
# by the release Gate. Stable .env values cannot disable or redirect it.
|
||||
export MEMIND_DEEPSEEK_DISABLE_THINKING=1
|
||||
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}"
|
||||
export MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
|
||||
export H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_ENABLED="${MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_ENABLED:-0}"
|
||||
export H5_WECHAT_MP_PAGE_DATA_AIDER_REVIEW_USERS="${MEMIND_CANARY_WECHAT_PAGE_DATA_AIDER_REVIEW_USER_IDS:-}"
|
||||
export AIDER_BIN="${MEMIND_CANARY_AIDER_BIN:-/opt/homebrew/bin/aider}"
|
||||
|
||||
@@ -3,10 +3,12 @@ import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { assertPortalRuntimePath, inspectPortalRuntime } from '../release-gate/artifact.mjs';
|
||||
import { assertDockerDaemonAvailable } from '../release-gate/preflight.mjs';
|
||||
|
||||
const root = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const runtime = assertPortalRuntimePath(path.join(root, '.runtime', 'portal'), { repoRoot: root });
|
||||
const image = process.env.RELEASE_GATE_NODE_IMAGE || 'node:24-bookworm';
|
||||
assertDockerDaemonAvailable();
|
||||
|
||||
async function runDocker(commandArgs, { input = '' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -2009,6 +2009,17 @@ export function createTkmindProxy({
|
||||
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
|
||||
throw error;
|
||||
}
|
||||
if (
|
||||
requireHistoricalImageIsolation
|
||||
&& imageIsolation.hasImageUrlContent
|
||||
&& !imageIsolation.updated
|
||||
) {
|
||||
const error = new Error(
|
||||
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'image_url_content_present'}`,
|
||||
);
|
||||
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
let finalUserMessage = userMessage;
|
||||
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
|
||||
|
||||
@@ -1569,7 +1569,7 @@ test('submitSessionReplyForUser rotates when assistant history still has image_u
|
||||
},
|
||||
{ requireHistoricalImageIsolation: true },
|
||||
),
|
||||
/historical_image_session_update_unsupported:image_url_content_present/,
|
||||
/historical_image_session_update_unsupported:(405|image_url_content_present)/,
|
||||
);
|
||||
assert.equal(replyBodies.length, 0);
|
||||
}, {
|
||||
|
||||
+126
-3
@@ -85,6 +85,7 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
|
||||
import {
|
||||
buildPageDataCollectFailureText,
|
||||
} from './mindspace-page-data-finish-guard.mjs';
|
||||
import { conversationHasImageUrlContent } from './chat-image-turn-scope.mjs';
|
||||
|
||||
export { buildWechatAgentPrompt };
|
||||
|
||||
@@ -1094,6 +1095,30 @@ export function isWechatHistoricalImageSessionError(message) {
|
||||
);
|
||||
}
|
||||
|
||||
export const WECHAT_IMAGE_STORED_ACK_TEXT =
|
||||
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
|
||||
|
||||
export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
|
||||
if (!recentMediaEntry?.items?.length) return '';
|
||||
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
|
||||
}
|
||||
|
||||
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
|
||||
if (!intent || typeof intent !== 'object') return intent;
|
||||
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
|
||||
const agentText = String(intent.agentText ?? '').trim();
|
||||
if (imageUrl && !agentText.includes(imageUrl)) {
|
||||
intent.agentText = agentText
|
||||
? `${agentText}\n\n[附件图片]: ${imageUrl}`
|
||||
: `[附件图片]: ${imageUrl}`;
|
||||
}
|
||||
delete intent.media;
|
||||
delete intent.attachment;
|
||||
delete intent.recentMediaItems;
|
||||
delete intent.recentMediaBatchId;
|
||||
return intent;
|
||||
}
|
||||
|
||||
export function isRecoverableWechatAgentSessionError(message) {
|
||||
const normalized = String(message ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
@@ -2166,15 +2191,23 @@ export function createWechatMpService({
|
||||
retainUnlinkedRoute = false,
|
||||
userContext = null,
|
||||
}) => {
|
||||
if (forceNew) {
|
||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||
}
|
||||
const workingDir = await userAuth.resolveWorkingDir(userId);
|
||||
let sessionPolicy =
|
||||
await userAuth.getAgentSessionPolicy(userId);
|
||||
const publishLayout = await userAuth.getUserPublishLayout(userId);
|
||||
const addressName = resolveWechatAddressName(userContext);
|
||||
let carriedSessionContentForNewSession = '';
|
||||
if (forceNew) {
|
||||
const routeBeforeForceNew = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||
if (routeBeforeForceNew?.agentSessionId) {
|
||||
carriedSessionContentForNewSession = await fetchLastSubstantiveAssistantFromSession(
|
||||
fetchForSession,
|
||||
routeBeforeForceNew.agentSessionId,
|
||||
);
|
||||
rememberedWechatContexts.delete(routeBeforeForceNew.agentSessionId);
|
||||
}
|
||||
await userAuth.clearWechatAgentRoute(config.appId, openid);
|
||||
}
|
||||
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
|
||||
if (existingRoute?.agentSessionId) {
|
||||
const now = Date.now();
|
||||
@@ -2386,6 +2419,53 @@ export function createWechatMpService({
|
||||
};
|
||||
};
|
||||
|
||||
const rotateWechatSessionIfImagePolluted = async ({
|
||||
userId,
|
||||
openid,
|
||||
sessionId,
|
||||
user,
|
||||
carriedSessionContent = '',
|
||||
}) => {
|
||||
if (!sessionId) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
try {
|
||||
const response = await fetchForSession(
|
||||
sessionId,
|
||||
`/sessions/${encodeURIComponent(sessionId)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
const payload = await readJsonResponse(response);
|
||||
const conversation = Array.isArray(payload?.conversation) ? payload.conversation : [];
|
||||
if (!conversationHasImageUrlContent(conversation)) {
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
logger.warn?.('WeChat MP rotating image-polluted agent session before reply:', {
|
||||
agentSessionId: sessionId,
|
||||
conversationLength: conversation.length,
|
||||
});
|
||||
const nextRoute = await ensureWechatAgentSession({
|
||||
userId,
|
||||
openid,
|
||||
forceNew: true,
|
||||
userContext: user,
|
||||
});
|
||||
const nextCarried = String(carriedSessionContent ?? '').trim()
|
||||
|| String(nextRoute.carriedSessionContent ?? '').trim();
|
||||
return {
|
||||
sessionId: nextRoute.sessionId,
|
||||
carriedSessionContent: nextCarried,
|
||||
rotated: true,
|
||||
isNewSession: nextRoute.isNewSession,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn?.('WeChat MP image-polluted session rotate skipped:', err);
|
||||
return { sessionId, carriedSessionContent, rotated: false };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
|
||||
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
|
||||
try {
|
||||
@@ -2693,6 +2773,26 @@ export function createWechatMpService({
|
||||
return { sessionId };
|
||||
}
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||
const pollutionRotation = await rotateWechatSessionIfImagePolluted({
|
||||
userId: user.userId,
|
||||
openid: inbound.fromUserName,
|
||||
sessionId,
|
||||
user,
|
||||
carriedSessionContent,
|
||||
});
|
||||
if (pollutionRotation.rotated) {
|
||||
sessionId = pollutionRotation.sessionId;
|
||||
route = {
|
||||
...route,
|
||||
sessionId,
|
||||
isNewSession: pollutionRotation.isNewSession ?? true,
|
||||
};
|
||||
if (pollutionRotation.carriedSessionContent) {
|
||||
carriedSessionContent = pollutionRotation.carriedSessionContent;
|
||||
}
|
||||
await ensureSessionProvider(sessionId);
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: true });
|
||||
}
|
||||
if (
|
||||
wechatIntent.kind === 'page.generate'
|
||||
&& sessionPageContinuation
|
||||
@@ -3157,6 +3257,13 @@ export function createWechatMpService({
|
||||
sessionId = route.sessionId;
|
||||
await ensureSessionProvider(sessionId);
|
||||
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
|
||||
if (historicalImageError) {
|
||||
prepareWechatIntentForHistoricalImageRetry(intent, {
|
||||
fallbackImageUrl: resolveWechatRecentMediaPublicUrl(
|
||||
recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()),
|
||||
),
|
||||
});
|
||||
}
|
||||
const retryId = crypto.randomUUID();
|
||||
const retryStartedAt = Date.now();
|
||||
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
|
||||
@@ -3683,6 +3790,22 @@ export function createWechatMpService({
|
||||
};
|
||||
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
|
||||
rememberRecentMedia(inbound.fromUserName, intent);
|
||||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||||
await userAuth.finishWechatMpMessage({
|
||||
appId: config.appId,
|
||||
openid: inbound.fromUserName,
|
||||
msgId: inbound.msgId,
|
||||
status: 'done',
|
||||
agentSessionId: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
contentType: 'application/xml; charset=utf-8',
|
||||
body: await buildPassiveReplyBody(WECHAT_IMAGE_STORED_ACK_TEXT),
|
||||
};
|
||||
} catch (error) {
|
||||
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
|
||||
return {
|
||||
|
||||
+370
-116
@@ -14,6 +14,9 @@ import {
|
||||
isRecoverableWechatAgentSessionError,
|
||||
isWechatAgentApiErrorText,
|
||||
isWechatHistoricalImageSessionError,
|
||||
WECHAT_IMAGE_STORED_ACK_TEXT,
|
||||
prepareWechatIntentForHistoricalImageRetry,
|
||||
resolveWechatRecentMediaPublicUrl,
|
||||
sanitizeWechatAgentOutboundText,
|
||||
loadWechatMpConfig,
|
||||
maybeAttachPublishedHtmlLink,
|
||||
@@ -391,7 +394,7 @@ test('WeChat session page continuation covers retry, edit, and poem edits', () =
|
||||
classifyWechatIntent({ msgType: 'text', agentText: '解读详细报告,做成页面' }),
|
||||
'解读详细报告,做成页面',
|
||||
),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3498,6 +3501,40 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
|
||||
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
|
||||
});
|
||||
|
||||
test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', () => {
|
||||
const intent = prepareWechatIntentForHistoricalImageRetry({
|
||||
agentText: '解读报告,生成页面',
|
||||
media: { publicUrl: 'https://example.com/report.png' },
|
||||
recentMediaItems: [{ media: { publicUrl: 'https://example.com/report.png' } }],
|
||||
recentMediaBatchId: 'batch-1',
|
||||
});
|
||||
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
|
||||
assert.equal(intent.media, undefined);
|
||||
assert.equal(intent.recentMediaItems, undefined);
|
||||
assert.equal(intent.recentMediaBatchId, undefined);
|
||||
});
|
||||
|
||||
test('prepareWechatIntentForHistoricalImageRetry can recover image url from recent media cache', () => {
|
||||
const intent = prepareWechatIntentForHistoricalImageRetry(
|
||||
{ agentText: '帮我把报告再详细点' },
|
||||
{ fallbackImageUrl: 'https://example.com/report.png' },
|
||||
);
|
||||
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
|
||||
assert.equal(intent.media, undefined);
|
||||
});
|
||||
|
||||
test('resolveWechatRecentMediaPublicUrl reads the latest remembered image', () => {
|
||||
assert.equal(
|
||||
resolveWechatRecentMediaPublicUrl({
|
||||
items: [
|
||||
{ media: { publicUrl: 'https://example.com/first.png' } },
|
||||
{ media: { publicUrl: 'https://example.com/second.png' } },
|
||||
],
|
||||
}),
|
||||
'https://example.com/second.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
@@ -4917,7 +4954,7 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = () => 'req-image';
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
@@ -4929,9 +4966,15 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
signature: signatureFor(token, timestamp, nonce),
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
assert.match(result.body, /<Content>/);
|
||||
await result.task;
|
||||
assert.equal(imageResult.status, 200);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
assert.equal(prompts.length, 0);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
|
||||
@@ -4941,17 +4984,13 @@ test('wechat mp wildcard media access persists image and routes image url into a
|
||||
}
|
||||
|
||||
assert.equal(prompts.length, 1);
|
||||
assert.match(prompts[0], /【微信服务号图片消息】/);
|
||||
assert.match(
|
||||
prompts[0],
|
||||
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
|
||||
);
|
||||
assert.match(prompts[0], /请分析刚才图片/);
|
||||
assert.equal(metadataCalls.length, 1);
|
||||
assert.equal(metadataCalls[0].source, 'wechat_mp');
|
||||
assert.equal(metadataCalls[0].msgType, 'image');
|
||||
assert.equal(metadataCalls[0].msgType, 'text');
|
||||
assert.equal(metadataCalls[0].imageUrls.length, 1);
|
||||
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//);
|
||||
assert.equal(detailCalls.length, 1);
|
||||
assert.equal(detailCalls.length, 2);
|
||||
assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
|
||||
});
|
||||
|
||||
@@ -5014,7 +5053,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
@@ -5022,7 +5061,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
@@ -5030,6 +5076,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.equal(submitCalls[0].userId, testUserId);
|
||||
assert.equal(submitCalls[0].sessionId, 'session-1');
|
||||
assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
|
||||
});
|
||||
@@ -5337,8 +5384,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-image-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5351,25 +5396,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已结合刚才图片分析主题。"}]}}\n\n',
|
||||
@@ -5419,28 +5445,21 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', new RegExp(WECHAT_IMAGE_STORED_ACK_TEXT.slice(0, 8)));
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await imageResult.task;
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
submitCalls[1].userMessage.metadata.imageUrls,
|
||||
submitCalls[0].userMessage.metadata.imageUrls,
|
||||
);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
});
|
||||
|
||||
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
|
||||
@@ -5449,8 +5468,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-report-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5463,25 +5480,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
|
||||
@@ -5531,29 +5529,311 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await imageResult.task;
|
||||
await followupResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.deepEqual(
|
||||
submitCalls[1].userMessage.metadata.imageUrls,
|
||||
submitCalls[0].userMessage.metadata.imageUrls,
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面');
|
||||
});
|
||||
|
||||
test('wechat mp rotates polluted image session before a content-edit follow-up', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-image-followup-rotate';
|
||||
const submitCalls = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let nextSessionId = 2;
|
||||
let routeCleared = false;
|
||||
const pollutedConversation = [
|
||||
{
|
||||
id: 'assistant-report',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '报告页面已生成。' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-image',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: false },
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
if (routeCleared || !activeSessionId) return null;
|
||||
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
},
|
||||
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
const body = sessionId === 'session-1'
|
||||
? { conversation: pollutedConversation }
|
||||
: { conversation: [] };
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","request_id":"req-followup","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已按你的要求补充报告细节。"}]}}\n\n',
|
||||
'data: {"type":"Finish","request_id":"req-followup","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
const originalRandomUuid = crypto.randomUUID;
|
||||
crypto.randomUUID = (() => {
|
||||
const ids = ['req-followup'];
|
||||
return () => ids.shift() ?? 'req-followup';
|
||||
})();
|
||||
try {
|
||||
const result = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '帮我把报告再详细一点' }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await result.task;
|
||||
} finally {
|
||||
crypto.randomUUID = originalRandomUuid;
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 1);
|
||||
assert.notEqual(submitCalls[0].sessionId, 'session-1');
|
||||
assert.equal(activeSessionId, submitCalls[0].sessionId);
|
||||
});
|
||||
|
||||
test('wechat mp simulates tang image report page and desensitized follow-up', async () => {
|
||||
const token = 'token';
|
||||
const timestamp = '1710000000';
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-tang-report-followup';
|
||||
const submitCalls = [];
|
||||
const customerReplies = [];
|
||||
let activeSessionId = 'session-1';
|
||||
let nextSessionId = 2;
|
||||
let routeCleared = false;
|
||||
let reportPageCompleted = false;
|
||||
let reportSessionId = null;
|
||||
const pollutedConversation = [
|
||||
{
|
||||
id: 'assistant-report',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: true },
|
||||
content: [{ type: 'text', text: '已解读化验报告并生成页面链接。' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-image',
|
||||
role: 'assistant',
|
||||
metadata: { userVisible: false },
|
||||
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
|
||||
},
|
||||
];
|
||||
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
mediaAnalysisGrayUsers: [testUserId],
|
||||
},
|
||||
userAuth: {
|
||||
async findWechatUserByOpenid() {
|
||||
return { userId: testUserId, status: 'active', nickname: '唐' };
|
||||
},
|
||||
async getWechatAgentRoute() {
|
||||
if (routeCleared || !activeSessionId) return null;
|
||||
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
|
||||
},
|
||||
async upsertWechatAgentRoute({ agentSessionId }) {
|
||||
activeSessionId = agentSessionId;
|
||||
routeCleared = false;
|
||||
},
|
||||
async clearWechatAgentRoute() {
|
||||
routeCleared = true;
|
||||
},
|
||||
},
|
||||
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
|
||||
sessionApiFetch: async (sessionId, pathname) => {
|
||||
if (pathname === `/sessions/${sessionId}`) {
|
||||
const conversation =
|
||||
reportSessionId && sessionId === reportSessionId && reportPageCompleted
|
||||
? pollutedConversation
|
||||
: [];
|
||||
return new Response(JSON.stringify({ conversation }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (pathname === `/sessions/${sessionId}/events`) {
|
||||
const isReportStream = submitCalls.length === 0;
|
||||
const text = isReportStream
|
||||
? '报告页面已生成:https://m.tkmind.cn/MindSpace/test/public/report.html'
|
||||
: '已按你的要求脱敏并补充报告细节。';
|
||||
return new Response(
|
||||
[
|
||||
`data: {"type":"Message","message":{"id":"assistant-${isReportStream ? 'report' : 'edit'}","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"${text}"}]}}\n\n`,
|
||||
'data: {"type":"Finish","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
|
||||
].join(''),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
|
||||
},
|
||||
submitSessionReply: async (input) => {
|
||||
submitCalls.push(input);
|
||||
if (!reportSessionId) {
|
||||
reportSessionId = input.sessionId;
|
||||
reportPageCompleted = true;
|
||||
}
|
||||
assert.equal(input.options?.requireHistoricalImageIsolation, true);
|
||||
return { ok: true };
|
||||
},
|
||||
wechatFetch: async (url, init = {}) => {
|
||||
if (String(url).includes('/cgi-bin/stable_token')) {
|
||||
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/media/get')) {
|
||||
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'image/png' },
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/cgi-bin/message/custom/send')) {
|
||||
customerReplies.push(JSON.parse(init.body ?? '{}'));
|
||||
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected wechat url: ${url}`);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const resetResult = await service.handleInboundMessage(
|
||||
inboundXml({ msgType: 'text', content: '换新会话', extraFields: { MsgId: 'tang-1' } }),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
if (resetResult.task) await resetResult.task;
|
||||
assert.equal(submitCalls.length, 0);
|
||||
|
||||
const imageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'image',
|
||||
content: '',
|
||||
extraFields: {
|
||||
MsgId: 'tang-2',
|
||||
MediaId: 'media-report',
|
||||
PicUrl: 'https://wx.example.com/report.png',
|
||||
},
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(imageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const reportResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'text',
|
||||
content: '解读报告并生成页面',
|
||||
extraFields: { MsgId: 'tang-3' },
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await reportResult.task;
|
||||
|
||||
const editResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
msgType: 'text',
|
||||
content: '帮我把真名脱敏,报告再详细点',
|
||||
extraFields: { MsgId: 'tang-4' },
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await editResult.task;
|
||||
} finally {
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.ok(submitCalls.length >= 2, `submitCalls=${submitCalls.length}`);
|
||||
const firstReportSubmit = submitCalls.find(
|
||||
(call) => Array.isArray(call.userMessage.metadata?.imageUrls) && call.userMessage.metadata.imageUrls.length > 0,
|
||||
);
|
||||
assert.ok(firstReportSubmit, 'missing report submit with imageUrls');
|
||||
assert.equal(firstReportSubmit.sessionId, reportSessionId);
|
||||
assert.notEqual(submitCalls.at(-1).sessionId, reportSessionId);
|
||||
assert.ok(customerReplies.length >= 2);
|
||||
const replyTexts = customerReplies.map((payload) => String(payload?.text?.content ?? ''));
|
||||
for (const text of replyTexts) {
|
||||
assert.doesNotMatch(text, /unknown variant [`']?image_url/i);
|
||||
assert.doesNotMatch(text, /Ran into this error:/i);
|
||||
}
|
||||
assert.ok(replyTexts.some((text) => /脱敏/.test(text)), replyTexts.join(' | '));
|
||||
assert.ok(
|
||||
replyTexts.some((text) => /report\.html/.test(text))
|
||||
|| submitCalls.some((call) => Array.isArray(call.userMessage.metadata?.imageUrls)),
|
||||
replyTexts.join(' | '),
|
||||
);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
|
||||
});
|
||||
|
||||
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
|
||||
@@ -5562,8 +5842,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
const nonce = 'nonce';
|
||||
const testUserId = 'test-user-multi-image-followup';
|
||||
const submitCalls = [];
|
||||
let eventCall = 0;
|
||||
let releaseFirst = null;
|
||||
const service = createBoundWechatService({
|
||||
token,
|
||||
config: {
|
||||
@@ -5576,25 +5854,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
},
|
||||
sessionApiFetch: async (_sessionId, pathname) => {
|
||||
if (pathname === '/sessions/session-1/events') {
|
||||
eventCall += 1;
|
||||
if (eventCall === 1) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
releaseFirst = () => {
|
||||
controller.enqueue(
|
||||
new TextEncoder().encode(
|
||||
'data: {"type":"Message","message":{"id":"assistant-first","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"第一张处理完成。"}]}}\n\n' +
|
||||
'data: {"type":"Finish"}\n\n',
|
||||
),
|
||||
);
|
||||
controller.close();
|
||||
};
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
[
|
||||
'data: {"type":"Message","message":{"id":"assistant-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n',
|
||||
@@ -5648,7 +5907,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(firstImageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const secondImageResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
@@ -5662,6 +5922,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
assert.equal(submitCalls.length, 0);
|
||||
assert.match(secondImageResult.body ?? '', /已收到图片/);
|
||||
|
||||
const followupResult = await service.handleInboundMessage(
|
||||
inboundXml({
|
||||
@@ -5670,12 +5932,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
}),
|
||||
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(submitCalls.length, 1);
|
||||
|
||||
releaseFirst();
|
||||
await firstImageResult.task;
|
||||
await secondImageResult.task;
|
||||
await followupResult.task;
|
||||
|
||||
const laterResult = await service.handleInboundMessage(
|
||||
@@ -5690,14 +5946,12 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
|
||||
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
assert.equal(submitCalls.length, 4);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1);
|
||||
assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2);
|
||||
assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/);
|
||||
assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/);
|
||||
assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined);
|
||||
assert.equal(submitCalls.length, 2);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/);
|
||||
assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/);
|
||||
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
|
||||
assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined);
|
||||
});
|
||||
|
||||
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ test('transcribeWechatVoiceViaRecoApi uploads mp3 and polls reco result', async
|
||||
},
|
||||
convertToMp3: async () => mp3Buffer,
|
||||
pollIntervalMs: 1,
|
||||
pollTimeoutMs: 50,
|
||||
pollTimeoutMs: 2_000,
|
||||
now: () => Date.now(),
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ export const PAGE_CONTENT_EDIT_PATTERN =
|
||||
const PAGE_LINK_MISSING_RETRY_PATTERN =
|
||||
/(?:页面|链接|新闻页|新闻页面|html).{0,16}(?:没有生成|没生成|未生成|没发|未发)/iu;
|
||||
|
||||
export const REPORT_PAGE_CONTINUATION_PATTERN =
|
||||
/(?:解读|分析|说明).{0,12}(?:详细)?(?:报告|化验|检验)|(?:报告|化验|检验).{0,12}(?:解读|做成|生成).{0,8}(?:页面|网页)/iu;
|
||||
|
||||
export function isWechatReportPageFollowup(wechatIntent, text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized || wechatIntent?.kind !== 'page.generate') return false;
|
||||
return REPORT_PAGE_CONTINUATION_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
export function isWechatPageLinkRetryText(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
return Boolean(normalized && PAGE_LINK_MISSING_RETRY_PATTERN.test(normalized));
|
||||
@@ -69,6 +78,7 @@ export function isWechatSessionPageContinuation(wechatIntent, text) {
|
||||
if (isWechatImmediateContextPageCreate(wechatIntent, normalized)) return true;
|
||||
if (wechatIntent?.kind === 'page.generate' && isWechatPageEditText(normalized)) return true;
|
||||
if (wechatIntent?.kind === 'chat.general' && isWechatContentEditFollowup(normalized)) return true;
|
||||
if (isWechatReportPageFollowup(wechatIntent, normalized)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,3 +37,12 @@ test('WeChat page continuation rejects full new-topic page requests', () => {
|
||||
assert.equal(intent.kind, 'page.generate');
|
||||
assert.equal(isWechatSessionPageContinuation(intent, intent.topic), false);
|
||||
});
|
||||
|
||||
test('WeChat page continuation recognizes report interpretation follow-ups', () => {
|
||||
const intent = classifyWechatIntent({
|
||||
msgType: 'text',
|
||||
agentText: '解读详细报告,做成页面',
|
||||
});
|
||||
assert.equal(intent.kind, 'page.generate');
|
||||
assert.equal(isWechatSessionPageContinuation(intent, intent.topic), true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user