Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41bf775c4c | |||
| 67bf2c2bbb | |||
| fb6865638a | |||
| 046704816f | |||
| 02f2dc6b17 |
@@ -6,6 +6,7 @@ import { Agent, fetch as undiciFetch } from 'undici';
|
||||
export const DEFAULT_DEEPSEEK_NO_THINK_PORT = 18036;
|
||||
export const DEFAULT_DEEPSEEK_UPSTREAM = 'https://api.deepseek.com';
|
||||
export const DEFAULT_MOONSHOT_UPSTREAM = 'https://api.moonshot.cn';
|
||||
export const DEEPSEEK_COMPAT_CONTRACT_VERSION = 1;
|
||||
|
||||
const insecureDispatcher = new Agent({
|
||||
connect: { rejectUnauthorized: false },
|
||||
@@ -256,6 +257,8 @@ export function createDeepseekNoThinkProxy({
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
ok: true,
|
||||
contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
deepseekThinking: 'disabled',
|
||||
upstream: normalizedUpstream,
|
||||
moonshotUpstream: normalizedMoonshotUpstream,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
decodedUpstreamResponseHeaders,
|
||||
deepseekDisableThinkingEnabled,
|
||||
flattenLocalJsonSchemaRefs,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
resolveDeepseekNoThinkProxyBaseUrl,
|
||||
resolveMoonshotCompatProxyBaseUrl,
|
||||
sanitizeMoonshotToolSchemas,
|
||||
startDeepseekNoThinkProxy,
|
||||
} from './deepseek-no-think-proxy.mjs';
|
||||
|
||||
test('injectDeepseekThinkingDisabled adds thinking.disabled when absent', () => {
|
||||
@@ -168,3 +170,22 @@ test('decodedUpstreamResponseHeaders drops stale compression metadata', () => {
|
||||
'content-type': 'text/event-stream',
|
||||
});
|
||||
});
|
||||
|
||||
test('compatibility proxy health identifies the enforced production contract', async (t) => {
|
||||
const server = await startDeepseekNoThinkProxy({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const address = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${address.port}/health`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), {
|
||||
ok: true,
|
||||
contractVersion: DEEPSEEK_COMPAT_CONTRACT_VERSION,
|
||||
deepseekThinking: 'disabled',
|
||||
upstream: 'https://api.deepseek.com',
|
||||
moonshotUpstream: 'https://api.moonshot.cn',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,31 @@ Portal 的首个生产动作必须是用户级灰度,不能直接整包替换
|
||||
| 灰度边缘隧道 | `105 127.0.0.1:19082` | 反向 SSH 到 103 的 18082;不复用已退役的 19081 链路 |
|
||||
| 候选 Portal | `127.0.0.1:18081` | 独立、无源码 runtime;使用被动运行时模式 |
|
||||
| 候选 goosed | `127.0.0.1:18015` | 独立容器并挂载候选 runtime,不重建稳定 9 个容器 |
|
||||
| DeepSeek 兼容代理 | `103 0.0.0.0:18036` | 仅供宿主机和候选容器使用;不接入 105 或公网入口 |
|
||||
|
||||
候选 Portal 通过 `MEMIND_PORTAL_H5_ROOT` 共享经审计的持久数据和数据库,但
|
||||
`MEMIND_RUNTIME_ROLE=candidate` 会禁止重复启动 MindSpace、Plaza、提醒、订阅续期和
|
||||
Agent 恢复扫描等单例后台任务。
|
||||
|
||||
候选 Portal 必须从候选 runtime 目录启动,使相对模块只解析到本次候选 artifact。
|
||||
候选 goosed 在 Docker 内执行扩展,MCP Node 和入口路径必须固定为
|
||||
`/usr/local/bin/node` 与 `/opt/portal/mindspace-sandbox-mcp.mjs`;禁止继承稳定
|
||||
`.env` 中仅宿主机可见的 `/Users/...` 路径。若日志出现
|
||||
`Failed to add extension` 和宿主机绝对路径的 `MODULE_NOT_FOUND`,应先核对候选
|
||||
进程环境与 `/opt/portal` 容器挂载,禁止把宿主机文件复制进容器临时修补。
|
||||
|
||||
DeepSeek V4 的工具轮次还必须经过候选 runtime 中的
|
||||
`deepseek-no-think-proxy.mjs`。候选启动脚本必须强制
|
||||
`MEMIND_DEEPSEEK_DISABLE_THINKING=1`,并把 goosed 自定义 provider 指向
|
||||
`http://host.docker.internal:18036/v1`。该代理通过独立 LaunchAgent 管理;
|
||||
发布必须同时验证 `/health` 的兼容契约和候选容器到该地址的连通性。灰度路由器把
|
||||
该健康地址纳入候选健康闭包,代理掉线时必须自动回落稳定 Portal。
|
||||
|
||||
若日志出现
|
||||
`The reasoning_content in the thinking mode must be passed back to the API.`,
|
||||
说明 DeepSeek 请求绕过了兼容契约。禁止只在线补环境变量:必须检查 artifact、
|
||||
LaunchAgent、Portal provider 同步和路由器依赖健康配置是否属于同一 release。
|
||||
|
||||
## 身份和回落规则
|
||||
|
||||
- `john`:按数据库中的唯一用户名或用户 ID 匹配。
|
||||
@@ -25,6 +45,7 @@ Agent 恢复扫描等单例后台任务。
|
||||
- 微信回调通过 `FromUserName` 和 `h5_user_wechat_identities` 只读解析到绑定账号,再用唯一账号标识匹配。
|
||||
- 未命中、无身份、过期/撤销会话、数据库异常:走稳定版本。
|
||||
- 候选 `/api/status` 不健康:自动走稳定版本。
|
||||
- DeepSeek 兼容代理不健康:自动走稳定版本。
|
||||
- SSE、附件、公开页和普通 API 均由同一反向代理转发;微信 XML body 必须原样保留。
|
||||
|
||||
## 发布入口
|
||||
@@ -42,12 +63,13 @@ bash scripts/release-portal-canary-prod.sh --yes
|
||||
2. 执行 103 只读预检。
|
||||
3. 上传经过验证的候选包。
|
||||
4. 创建稳定 live 全量备份、持久目录备份和 105 两份活动 nginx 配置备份,并校验完整性。
|
||||
5. 启动独立候选 goosed、候选 Portal 和灰度路由器。
|
||||
6. 验证不可变身份配置、稳定/候选健康和路由器诊断。
|
||||
7. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。
|
||||
8. 短暂阻止新 Agent Run,排空活动任务。
|
||||
9. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。
|
||||
10. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。
|
||||
5. 启动独立候选 goosed 和 DeepSeek 兼容代理,验证兼容契约及容器连通性。
|
||||
6. 启动候选 Portal 和灰度路由器。
|
||||
7. 验证不可变身份配置、稳定/候选/依赖健康和路由器诊断。
|
||||
8. 启动独立的 `105:19082 -> 103:18082` 灰度隧道并从 105 验证。
|
||||
9. 短暂阻止新 Agent Run,排空活动任务。
|
||||
10. 逐文件原子替换、验证并 reload 105 nginx,把活动上游从 `58.38.22.103:8081` 切到 `127.0.0.1:19082`。
|
||||
11. 主动停止候选,证明目标身份会自动回落稳定版,再恢复候选。
|
||||
|
||||
任一步失败都会把 105 nginx 恢复到稳定 `58.38.22.103:8081`,停止灰度隧道和候选服务,
|
||||
并保留候选目录与备份证据。活动 nginx 配置变更只能由已提交的发布脚本执行,禁止在线手改。
|
||||
@@ -61,7 +83,7 @@ bash scripts/rollback-portal-canary-prod.sh --yes
|
||||
回滚只执行以下动作:
|
||||
|
||||
- 把 105 两份活动 nginx 上游恢复到稳定 Portal `58.38.22.103:8081` 并验证 reload。
|
||||
- 停止灰度隧道、灰度路由器、候选 Portal 和候选 goosed。
|
||||
- 停止灰度隧道、灰度路由器、候选 Portal、候选 goosed 和 DeepSeek 兼容代理。
|
||||
- 清除发布排水标记。
|
||||
- 验证稳定 Portal 和 105 HTTPS 入口健康。
|
||||
|
||||
|
||||
@@ -210,6 +210,37 @@ Provider 'custom_deepseek' is not configured
|
||||
|
||||
注意:DeepSeek API key / provider secret 是运行配置,不应该烘焙进镜像。
|
||||
|
||||
### 4.7 DeepSeek 工具轮次兼容必须与生产拓扑一致
|
||||
|
||||
DeepSeek V4 思考模式在工具调用后的下一轮要求客户端回传上一轮
|
||||
`reasoning_content`。当前 goosed 工具链不能保证完整回传,因此生产候选必须使用
|
||||
artifact 内的 `deepseek-no-think-proxy.mjs`,由独立 LaunchAgent 在 `18036`
|
||||
启动,并强制候选 Portal 设置:
|
||||
|
||||
```bash
|
||||
MEMIND_DEEPSEEK_DISABLE_THINKING=1
|
||||
MEMIND_DEEPSEEK_NO_THINK_PORT=18036
|
||||
MEMIND_GOOSED_HOST_GATEWAY=host.docker.internal
|
||||
```
|
||||
|
||||
发布不能只看 Portal `/api/status`。还必须验证:
|
||||
|
||||
- `http://127.0.0.1:18036/health` 返回兼容契约版本和
|
||||
`deepseekThinking=disabled`;
|
||||
- `goosed-prod-canary` 能访问
|
||||
`http://host.docker.internal:18036/health`;
|
||||
- 灰度路由器把该地址纳入候选健康闭包;
|
||||
- 失败清理和回滚会停止对应 LaunchAgent。
|
||||
|
||||
Gate 本地栈和生产候选必须使用同一组开关与进程依赖。若出现:
|
||||
|
||||
```text
|
||||
The reasoning_content in the thinking mode must be passed back to the API.
|
||||
```
|
||||
|
||||
说明实际生产请求绕过了兼容契约;即使场景 Gate 全部通过,也必须阻断发布并排查
|
||||
环境漂移。
|
||||
|
||||
## 5. goosed-prod 镜像更新必看
|
||||
|
||||
当前已验证镜像:
|
||||
|
||||
@@ -800,13 +800,13 @@ test('syncProfileToGoosed writes provider, model and secret keys for builtin', a
|
||||
);
|
||||
});
|
||||
|
||||
test('syncProfileToGoosed routes local DeepSeek through the no-thinking proxy', async () => {
|
||||
test('syncProfileToGoosed routes explicitly protected production DeepSeek through the compatibility proxy', async () => {
|
||||
const previousProfile = process.env.MEMIND_RUNTIME_PROFILE;
|
||||
const previousDisableThinking = process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
const previousProxyBase = process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
const previousGateway = process.env.MEMIND_GOOSED_HOST_GATEWAY;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'local';
|
||||
delete process.env.MEMIND_DEEPSEEK_DISABLE_THINKING;
|
||||
process.env.MEMIND_RUNTIME_PROFILE = 'production';
|
||||
process.env.MEMIND_DEEPSEEK_DISABLE_THINKING = '1';
|
||||
delete process.env.MEMIND_DEEPSEEK_NO_THINK_BASE_URL;
|
||||
process.env.MEMIND_GOOSED_HOST_GATEWAY = 'host.lima.internal';
|
||||
|
||||
|
||||
+46
-2
@@ -244,9 +244,43 @@ function appendForwardedFor(current, remoteAddress) {
|
||||
return values.join(', ');
|
||||
}
|
||||
|
||||
function dependencyHealthRequest(target, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const request = http.request(
|
||||
{
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
method: 'GET',
|
||||
path: `${target.pathname}${target.search}`,
|
||||
headers: { connection: 'close' },
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
response.once('end', () => resolve(response.statusCode === 200));
|
||||
},
|
||||
);
|
||||
request.setTimeout(timeoutMs, () => request.destroy());
|
||||
request.once('error', () => resolve(false));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeLoopbackHealthTarget(value, label) {
|
||||
const target = new URL(String(value ?? ''));
|
||||
if (
|
||||
target.protocol !== 'http:'
|
||||
|| !['127.0.0.1', 'localhost', '::1'].includes(target.hostname)
|
||||
) {
|
||||
throw new Error(`${label} must be a loopback http URL`);
|
||||
}
|
||||
target.hash = '';
|
||||
return target;
|
||||
}
|
||||
|
||||
export function createMemindCanaryProxy({
|
||||
stableTarget,
|
||||
candidateTarget,
|
||||
candidateHealthDependencies = [],
|
||||
policy,
|
||||
identityResolver,
|
||||
diagnosticSecret,
|
||||
@@ -256,6 +290,8 @@ export function createMemindCanaryProxy({
|
||||
} = {}) {
|
||||
const stable = normalizeLoopbackTarget(stableTarget, 'stable target');
|
||||
const candidate = normalizeLoopbackTarget(candidateTarget, 'candidate target');
|
||||
const candidateDependencies = candidateHealthDependencies.map((value, index) =>
|
||||
normalizeLoopbackHealthTarget(value, `candidate health dependency ${index + 1}`));
|
||||
if (!policy) throw new Error('canary proxy requires a routing policy');
|
||||
if (!identityResolver) throw new Error('canary proxy requires an identity resolver');
|
||||
if (!diagnosticSecret) throw new Error('canary proxy requires a diagnostic secret');
|
||||
@@ -269,8 +305,13 @@ export function createMemindCanaryProxy({
|
||||
return healthCache.healthy;
|
||||
}
|
||||
if (!healthPromise) {
|
||||
healthPromise = healthRequest(candidate, candidateHealthTimeoutMs, 'candidate')
|
||||
.then((healthy) => {
|
||||
healthPromise = Promise.all([
|
||||
healthRequest(candidate, candidateHealthTimeoutMs, 'candidate'),
|
||||
...candidateDependencies.map((target) =>
|
||||
dependencyHealthRequest(target, candidateHealthTimeoutMs)),
|
||||
])
|
||||
.then((results) => {
|
||||
const healthy = results.every(Boolean);
|
||||
healthCache = { checkedAt: Date.now(), healthy };
|
||||
return healthy;
|
||||
})
|
||||
@@ -539,6 +580,9 @@ async function main() {
|
||||
stableTarget: process.env.MEMIND_CANARY_STABLE_URL || 'http://127.0.0.1:8081',
|
||||
candidateTarget:
|
||||
process.env.MEMIND_CANARY_CANDIDATE_URL || 'http://127.0.0.1:18081',
|
||||
candidateHealthDependencies: csvValues(
|
||||
process.env.MEMIND_CANARY_CANDIDATE_HEALTH_URLS,
|
||||
),
|
||||
policy,
|
||||
identityResolver,
|
||||
diagnosticSecret: process.env.MEMIND_CANARY_DIAGNOSTIC_SECRET,
|
||||
|
||||
@@ -287,6 +287,54 @@ test('identity lookup errors fail closed to stable', async (t) => {
|
||||
assert.equal(response.headers['x-memind-runtime-route'], 'stable');
|
||||
});
|
||||
|
||||
test('candidate health fails closed when its DeepSeek compatibility dependency is down', async (t) => {
|
||||
const stableServer = upstream('stable');
|
||||
const candidateServer = upstream('candidate');
|
||||
let dependencyHealthy = true;
|
||||
const dependencyServer = http.createServer((req, res) => {
|
||||
res.writeHead(req.url === '/health' && dependencyHealthy ? 200 : 503);
|
||||
res.end();
|
||||
});
|
||||
const stableUrl = await listen(stableServer);
|
||||
const candidateUrl = await listen(candidateServer);
|
||||
const dependencyUrl = await listen(dependencyServer);
|
||||
const proxy = createMemindCanaryProxy({
|
||||
stableTarget: stableUrl,
|
||||
candidateTarget: candidateUrl,
|
||||
candidateHealthDependencies: [`${dependencyUrl}/health`],
|
||||
policy: createCanaryPolicy({ usernames: ['john'] }),
|
||||
identityResolver: {
|
||||
async fromSessionToken() {
|
||||
return { id: 'user-john', username: 'john' };
|
||||
},
|
||||
async fromWechatOpenid() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
diagnosticSecret: 'diagnostic-secret',
|
||||
candidateHealthTtlMs: 0,
|
||||
});
|
||||
const proxyUrl = await listen(proxy.server);
|
||||
t.after(async () => {
|
||||
await close(proxy.server);
|
||||
await close(stableServer);
|
||||
await close(candidateServer);
|
||||
await close(dependencyServer);
|
||||
});
|
||||
|
||||
const candidate = await request(proxyUrl, {
|
||||
headers: { cookie: 'tkmind_user_session=john-token' },
|
||||
});
|
||||
assert.equal(candidate.headers['x-memind-runtime-route'], 'candidate');
|
||||
|
||||
dependencyHealthy = false;
|
||||
const fallback = await request(proxyUrl, {
|
||||
headers: { cookie: 'tkmind_user_session=john-token' },
|
||||
});
|
||||
assert.equal(fallback.body, 'stable:');
|
||||
assert.equal(fallback.headers['x-memind-runtime-route'], 'stable');
|
||||
});
|
||||
|
||||
test('candidate health requires the explicit candidate runtime role', async (t) => {
|
||||
const stableServer = upstream('stable');
|
||||
const wrongRoleServer = upstream('stable');
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'server.mjs',
|
||||
'memind-canary-proxy.mjs',
|
||||
'deepseek-no-think-proxy.mjs',
|
||||
'wechat-mp.bundle.mjs',
|
||||
'mindspace-sandbox-mcp.mjs',
|
||||
'tkmind-search-mcp.mjs',
|
||||
@@ -16,6 +17,7 @@ export const REQUIRED_PORTAL_RUNTIME_PATHS = Object.freeze([
|
||||
'scripts/run-memind-portal-prod.sh',
|
||||
'scripts/run-memind-portal-candidate.sh',
|
||||
'scripts/run-memind-canary-proxy-prod.sh',
|
||||
'scripts/run-deepseek-compat-proxy-candidate.sh',
|
||||
'scripts/goosed-canary.compose.yml',
|
||||
'scripts/check-mindspace-public-links.mjs',
|
||||
'scripts/load-env.mjs',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { startDeepseekNoThinkProxy } from '../deepseek-no-think-proxy.mjs';
|
||||
|
||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
test('sanitized production tool-round replay reaches DeepSeek with thinking disabled', async (t) => {
|
||||
const forwarded = [];
|
||||
const proxy = await startDeepseekNoThinkProxy({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
fetchImpl: async (_url, init) => {
|
||||
forwarded.push(JSON.parse(String(init.body)));
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { role: 'assistant', content: 'synthetic result' } }],
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
t.after(() => new Promise((resolve) => proxy.close(resolve)));
|
||||
const address = proxy.address();
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${address.port}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: 'Bearer synthetic-key',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-pro',
|
||||
thinking: { type: 'enabled' },
|
||||
messages: [
|
||||
{ role: 'user', content: 'Use the synthetic filesystem tool.' },
|
||||
{ role: 'assistant', tool_calls: [{ id: 'call-1', type: 'function' }] },
|
||||
{ role: 'tool', tool_call_id: 'call-1', content: 'synthetic file list' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(forwarded.length, 1);
|
||||
assert.deepEqual(forwarded[0].thinking, { type: 'disabled' });
|
||||
assert.equal(forwarded[0].messages[2].role, 'tool');
|
||||
});
|
||||
|
||||
test('Gate, artifact, candidate routing and rollback share one compatibility contract', async () => {
|
||||
const [
|
||||
builder,
|
||||
localStack,
|
||||
candidateRunner,
|
||||
compatRunner,
|
||||
canaryRelease,
|
||||
canaryRollback,
|
||||
] = await Promise.all([
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'release-gate', 'local-stack.mjs'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh'), 'utf8'),
|
||||
fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||
'utf8',
|
||||
),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'release-portal-canary-prod.sh'), 'utf8'),
|
||||
fs.readFile(path.join(ROOT, 'scripts', 'rollback-portal-canary-prod.sh'), 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(builder, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(
|
||||
localStack,
|
||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
||||
);
|
||||
assert.match(candidateRunner, /export MEMIND_DEEPSEEK_DISABLE_THINKING=1/);
|
||||
assert.match(candidateRunner, /export MEMIND_GOOSED_HOST_GATEWAY=host\.docker\.internal/);
|
||||
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, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(canaryRollback, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
});
|
||||
@@ -380,7 +380,7 @@ export async function createLocalGateStack({
|
||||
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
|
||||
deepseekProxyChild = spawn(
|
||||
process.execPath,
|
||||
[path.join(root, 'deepseek-no-think-proxy.mjs')],
|
||||
[path.join(resolvedPortalRoot, 'deepseek-no-think-proxy.mjs')],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
@@ -41,6 +42,21 @@ test('production release verifies gate report before any 103 connection', async
|
||||
assert.match(source, /禁止 ALLOW_PORTAL_RELEASE_SCOPE_BYPASS/);
|
||||
});
|
||||
|
||||
test('production stable release verifies canary promotion evidence before gate checks', async () => {
|
||||
const source = await fs.readFile(
|
||||
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');
|
||||
assert.ok(promotionIndex > 0, 'missing canary promotion evidence verifier');
|
||||
assert.ok(gateIndex > promotionIndex, 'gate verification must follow promotion evidence');
|
||||
assert.doesNotMatch(source, /在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run/);
|
||||
assert.match(source, /read_agent_run_status_json/);
|
||||
assert.match(source, /sed -n '\/\^\{/);
|
||||
assert.match(source, /for _ in \$\(seq 1 120\)/);
|
||||
});
|
||||
|
||||
test('runtime builder removes every persisted path forbidden by artifact policy', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'),
|
||||
@@ -62,6 +78,16 @@ test('runtime builder materializes and declares required Linux ARM64 native pack
|
||||
assert.match(source, /缺少原生 \.node 文件/);
|
||||
});
|
||||
|
||||
test('runtime builder bundles the DeepSeek compatibility dependency into the artifact', async () => {
|
||||
const source = await fs.readFile(
|
||||
path.join(ROOT, 'scripts', 'build-portal-runtime.mjs'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /bundleDeepseekCompatProxy/);
|
||||
assert.match(source, /--outfile=\.runtime\/portal\/deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(source, /await bundleDeepseekCompatProxy\(\)/);
|
||||
});
|
||||
|
||||
test('packaged runtime gate isolates persistent roots and rejects artifact mutation', async () => {
|
||||
const localStackSource = await fs.readFile(
|
||||
path.join(ROOT, 'release-gate', 'local-stack.mjs'),
|
||||
@@ -69,7 +95,10 @@ test('packaged runtime gate isolates persistent roots and rejects artifact mutat
|
||||
);
|
||||
assert.match(localStackSource, /MEMIND_PORTAL_H5_ROOT: sandboxRoot/);
|
||||
assert.match(localStackSource, /MEMIND_DEEPSEEK_DISABLE_THINKING: '1'/);
|
||||
assert.match(localStackSource, /deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(
|
||||
localStackSource,
|
||||
/path\.join\(resolvedPortalRoot, 'deepseek-no-think-proxy\.mjs'\)/,
|
||||
);
|
||||
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_MODE: 'shadow'/);
|
||||
assert.match(localStackSource, /MEMIND_ORCHESTRATOR_PAGE_DATA_VALIDATION_GATE_ENABLED: '1'/);
|
||||
assert.match(localStackSource, /H5_USERS_ROOT: usersRoot/);
|
||||
@@ -118,6 +147,9 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
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 candidateStart = source.indexOf('Start the passive candidate Portal on 18081');
|
||||
const deepseekCompatStart = source.indexOf(
|
||||
'Start the DeepSeek tool-round compatibility proxy on 18036',
|
||||
);
|
||||
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(
|
||||
@@ -128,7 +160,8 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.ok(persistBackup > fullBackup);
|
||||
assert.ok(edgeBackup > persistBackup);
|
||||
assert.ok(goosedStart > edgeBackup);
|
||||
assert.ok(candidateStart > goosedStart);
|
||||
assert.ok(deepseekCompatStart > goosedStart);
|
||||
assert.ok(candidateStart > deepseekCompatStart);
|
||||
assert.ok(proxyStart > candidateStart);
|
||||
assert.ok(tunnelStart > proxyStart);
|
||||
assert.ok(edgeSwitch > tunnelStart);
|
||||
@@ -139,6 +172,21 @@ test('production canary keeps stable 8081 live and switches only after verified
|
||||
assert.match(source, /nginx -t/);
|
||||
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, /deepseek-no-think-proxy\.mjs/);
|
||||
assert.match(source, /run-deepseek-compat-proxy-candidate\.sh/);
|
||||
assert.match(source, /MEMIND_CANARY_CANDIDATE_HEALTH_URLS/);
|
||||
assert.match(source, /deepseek_compat_healthy/);
|
||||
assert.match(source, /bootout.*DEEPSEEK_COMPAT_LABEL/);
|
||||
assert.match(
|
||||
source,
|
||||
/edge_ssh\(\) \{\s*#.*\n\s*#.*\n\s*ssh -n -o BatchMode=yes/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/ssh -o BatchMode=yes -o ConnectTimeout=10 "\$\{EDGE_HOST\}" \/bin\/bash <<EDGE_SWITCH/,
|
||||
);
|
||||
assert.doesNotMatch(source, /edge_ssh \/bin\/bash <<EDGE_SWITCH/);
|
||||
assert.match(source, /CANARY_USERNAMES="john"/);
|
||||
assert.match(source, /CANARY_WECHAT_USER_IDS="wx_ul610et8"/);
|
||||
assert.match(source, /route-probe\?\$\{probe_query\}/);
|
||||
@@ -154,6 +202,139 @@ 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) => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-canary-runner-'));
|
||||
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
|
||||
|
||||
const stableRoot = path.join(tempRoot, 'stable');
|
||||
const fakeNode = path.join(tempRoot, 'node');
|
||||
await fs.mkdir(stableRoot);
|
||||
await fs.writeFile(
|
||||
path.join(stableRoot, '.env'),
|
||||
[
|
||||
'GOOSED_MCP_NODE_PATH=/opt/homebrew/opt/node@24/bin/node',
|
||||
'GOOSED_MCP_SERVER_PATH=/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs',
|
||||
'MEMIND_DEEPSEEK_DISABLE_THINKING=0',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_PORT=9999',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_BASE_URL=http://wrong.invalid/v1',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_HOST=wrong.invalid',
|
||||
'MEMIND_GOOSED_HOST_GATEWAY=wrong.invalid',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakeNode,
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'printf "cwd=%s\\n" "$PWD"',
|
||||
'printf "mcp_node=%s\\n" "$GOOSED_MCP_NODE_PATH"',
|
||||
'printf "mcp_server=%s\\n" "$GOOSED_MCP_SERVER_PATH"',
|
||||
'printf "deepseek_disable=%s\\n" "$MEMIND_DEEPSEEK_DISABLE_THINKING"',
|
||||
'printf "deepseek_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"',
|
||||
'printf "deepseek_gateway=%s\\n" "$MEMIND_GOOSED_HOST_GATEWAY"',
|
||||
'printf "deepseek_base=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_BASE_URL-unset}"',
|
||||
'printf "deepseek_host=%s\\n" "${MEMIND_DEEPSEEK_NO_THINK_HOST-unset}"',
|
||||
'printf "entrypoint=%s\\n" "$1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[path.join(ROOT, 'scripts', 'run-memind-portal-candidate.sh')],
|
||||
{
|
||||
cwd: '/',
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MEMIND_CANARY_STABLE_ROOT: stableRoot,
|
||||
MEMIND_CANARY_RELEASE_ID: 'test-release',
|
||||
MEMIND_CANARY_CANDIDATE_PORT: '65534',
|
||||
NODE_BIN: fakeNode,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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_server=\/opt\/portal\/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_base=unset$/m);
|
||||
assert.match(result.stdout, /^deepseek_host=unset$/m);
|
||||
assert.match(result.stdout, new RegExp(`^entrypoint=${ROOT}/server\\.mjs$`, 'm'));
|
||||
});
|
||||
|
||||
test('canary rollback stops the DeepSeek compatibility process with the other canary services', async () => {
|
||||
const source = await fs.readFile(CANARY_ROLLBACK, 'utf8');
|
||||
assert.match(
|
||||
source,
|
||||
/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/);
|
||||
});
|
||||
|
||||
test('DeepSeek compatibility runner preserves stable upstream config but enforces candidate controls', async (t) => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-deepseek-runner-'));
|
||||
t.after(() => fs.rm(tempRoot, { recursive: true, force: true }));
|
||||
|
||||
const stableRoot = path.join(tempRoot, 'stable');
|
||||
const fakeNode = path.join(tempRoot, 'node');
|
||||
await fs.mkdir(stableRoot);
|
||||
await fs.writeFile(
|
||||
path.join(stableRoot, '.env'),
|
||||
[
|
||||
'DEEPSEEK_API_BASE_URL=https://synthetic-upstream.invalid/v1',
|
||||
'MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=0',
|
||||
'MEMIND_DEEPSEEK_NO_THINK_PORT=9999',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.writeFile(
|
||||
fakeNode,
|
||||
[
|
||||
'#!/usr/bin/env bash',
|
||||
'printf "proxy_entrypoint=%s\\n" "$MEMIND_DEEPSEEK_PROXY_ENTRYPOINT"',
|
||||
'printf "proxy_port=%s\\n" "$MEMIND_DEEPSEEK_NO_THINK_PORT"',
|
||||
'printf "upstream=%s\\n" "$DEEPSEEK_API_BASE_URL"',
|
||||
'printf "entrypoint=%s\\n" "$1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[path.join(ROOT, 'scripts', 'run-deepseek-compat-proxy-candidate.sh')],
|
||||
{
|
||||
cwd: '/',
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MEMIND_CANARY_STABLE_ROOT: stableRoot,
|
||||
MEMIND_CANARY_DEEPSEEK_PROXY_PORT: '18036',
|
||||
NODE_BIN: fakeNode,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /^proxy_entrypoint=1$/m);
|
||||
assert.match(result.stdout, /^proxy_port=18036$/m);
|
||||
assert.match(result.stdout, /^upstream=https:\/\/synthetic-upstream\.invalid\/v1$/m);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
new RegExp(`^entrypoint=${ROOT}/deepseek-no-think-proxy\\.mjs$`, 'm'),
|
||||
);
|
||||
});
|
||||
|
||||
test('canary release and rollback remote shells remain syntactically valid', async () => {
|
||||
const releaseSource = await fs.readFile(CANARY_RELEASE, 'utf8');
|
||||
const rollbackSource = await fs.readFile(CANARY_ROLLBACK, 'utf8');
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "PRC-REL-002",
|
||||
"scenario_ids": ["REL-06", "REL-09", "REL-11"],
|
||||
"failure_signature": "gate-production-drift-deepseek-thinking-tool-round-reasoning-content-replay-rejected",
|
||||
"synthetic_intent": "验证工具调用后的合成 DeepSeek 会话经过生产候选兼容链路,禁用思考模式,并在兼容依赖失效时让灰度流量回落稳定版本。",
|
||||
"source_batch_id": "incident-20260726-deepseek-tool-round",
|
||||
"evidence_digest": "97171fe1d94e0297cb0b932cf1ff18e8d1760519300f7308b9f8cc4e9517e022",
|
||||
"contains_personal_data": false,
|
||||
"privacy_review": {
|
||||
"reviewed_by": "release-gate-maintainers",
|
||||
"reviewed_at": "2026-07-26T23:55:00+08:00"
|
||||
},
|
||||
"automation": {
|
||||
"command": ["node", "--test", "release-gate/deepseek-production-parity.test.mjs"]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_ids": ["PRC-REL-001"],
|
||||
"fixture_ids": ["PRC-REL-001", "PRC-REL-002"],
|
||||
"reviewed_by": "release-gate-maintainers",
|
||||
"reviewed_at": "2026-07-26T12:00:00+08:00",
|
||||
"notes": "仅收录已脱敏的 runtime 启动回归签名;不包含聊天原文、用户身份或生产地址。"
|
||||
"notes": "仅收录已脱敏的 runtime 启动和生产配置同构回归签名;不包含聊天原文、用户身份或生产地址。"
|
||||
}
|
||||
|
||||
@@ -156,6 +156,26 @@ async function bundleCanaryProxy() {
|
||||
await run(esbuildBin, args);
|
||||
}
|
||||
|
||||
async function bundleDeepseekCompatProxy() {
|
||||
if (!(await exists(esbuildBin))) {
|
||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||
}
|
||||
console.log('==> 打包 DeepSeek 工具轮次兼容代理');
|
||||
const args = [
|
||||
'deepseek-no-think-proxy.mjs',
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=esm',
|
||||
`--target=${runtimeNodeTarget}`,
|
||||
'--outfile=.runtime/portal/deepseek-no-think-proxy.mjs',
|
||||
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
];
|
||||
for (const pkg of externalPackages) {
|
||||
args.push(`--external:${pkg}`);
|
||||
}
|
||||
await run(esbuildBin, args);
|
||||
}
|
||||
|
||||
async function bundleSandboxMcp() {
|
||||
if (!(await exists(esbuildBin))) {
|
||||
throw new Error(`未找到 esbuild: ${esbuildBin}`);
|
||||
@@ -409,6 +429,7 @@ async function writeMetadata() {
|
||||
for (const scriptName of [
|
||||
'run-memind-portal-candidate.sh',
|
||||
'run-memind-canary-proxy-prod.sh',
|
||||
'run-deepseek-compat-proxy-candidate.sh',
|
||||
]) {
|
||||
const source = path.join(root, 'scripts', scriptName);
|
||||
if (!(await exists(source))) {
|
||||
@@ -527,6 +548,7 @@ async function writeMetadata() {
|
||||
' tkmind-excel-mcp.mjs (esbuild bundle; required when Excel Analyst is enabled)',
|
||||
' wechat-mp.bundle.mjs (esbuild bundle; hot-swappable WeChat MP module)',
|
||||
' memind-canary-proxy.mjs (immutable-identity stable/candidate router)',
|
||||
' deepseek-no-think-proxy.mjs (DeepSeek V4 tool-round compatibility proxy)',
|
||||
'',
|
||||
'Post-deploy validation (scripts/check-mindspace-public-links.mjs):',
|
||||
' Scans MindSpace/*/public/*.html for missing relative href/src/cover targets.',
|
||||
@@ -598,6 +620,7 @@ async function main() {
|
||||
await copyRuntimeAssets();
|
||||
await bundleServer();
|
||||
await bundleCanaryProxy();
|
||||
await bundleDeepseekCompatProxy();
|
||||
await bundleWechatMp();
|
||||
await bundleSandboxMcp();
|
||||
await bundleMindSearchMcp();
|
||||
@@ -611,6 +634,10 @@ async function main() {
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-candidate.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-canary-proxy-prod.sh'), 0o755);
|
||||
await fs.chmod(
|
||||
path.join(runtimeRoot, 'scripts', 'run-deepseek-compat-proxy-candidate.sh'),
|
||||
0o755,
|
||||
);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-stream-runtime.mjs'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755);
|
||||
|
||||
@@ -13,9 +13,11 @@ RUNTIME_ROOT="${ROOT}/.runtime/portal"
|
||||
PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate"
|
||||
CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy"
|
||||
CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel"
|
||||
DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate"
|
||||
EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}"
|
||||
CANARY_PROXY_PORT=18082
|
||||
CANARY_TUNNEL_REMOTE_PORT=19082
|
||||
DEEPSEEK_COMPAT_PORT=18036
|
||||
EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf"
|
||||
EDGE_WECHAT_CONFIG="/etc/nginx/conf.d/wechat.m.tkmind.cn.conf"
|
||||
CANARY_USERNAMES="john"
|
||||
@@ -46,6 +48,7 @@ Deploys a candidate Portal beside the stable 103 runtime:
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -123,6 +126,7 @@ say "Run release source guards"
|
||||
required_runtime_paths=(
|
||||
server.mjs
|
||||
memind-canary-proxy.mjs
|
||||
deepseek-no-think-proxy.mjs
|
||||
wechat-mp.bundle.mjs
|
||||
mindspace-sandbox-mcp.mjs
|
||||
tkmind-search-mcp.mjs
|
||||
@@ -131,6 +135,7 @@ required_runtime_paths=(
|
||||
package.json
|
||||
scripts/run-memind-portal-candidate.sh
|
||||
scripts/run-memind-canary-proxy-prod.sh
|
||||
scripts/run-deepseek-compat-proxy-candidate.sh
|
||||
scripts/goosed-canary.compose.yml
|
||||
scripts/memind-portal-tunnel.sh
|
||||
)
|
||||
@@ -155,7 +160,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"
|
||||
echo "routing=stable:8081,proxy:18082,edge-tunnel:19082,candidate:18081,goosed-canary:18015,deepseek-compat:18036"
|
||||
echo "canary_usernames=${CANARY_USERNAMES}"
|
||||
echo "canary_wechat_user_ids=${CANARY_WECHAT_USER_IDS}"
|
||||
echo "manual_environment_changes=none"
|
||||
@@ -174,6 +179,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
&& test -f '${GOOSED_DIR}/docker-compose.prod.yml' \
|
||||
&& 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: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 \
|
||||
@@ -214,9 +220,11 @@ ssh -o BatchMode=yes "${HOST}" \
|
||||
PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \
|
||||
CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \
|
||||
CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \
|
||||
DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \
|
||||
EDGE_HOST='${EDGE_HOST}' \
|
||||
CANARY_PROXY_PORT='${CANARY_PROXY_PORT}' \
|
||||
CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \
|
||||
DEEPSEEK_COMPAT_PORT='${DEEPSEEK_COMPAT_PORT}' \
|
||||
EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \
|
||||
EDGE_WECHAT_CONFIG='${EDGE_WECHAT_CONFIG}' \
|
||||
CANARY_USERNAMES='${CANARY_USERNAMES}' \
|
||||
@@ -234,6 +242,7 @@ PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before-canary.t
|
||||
CANDIDATE_PLIST="${HOME}/Library/LaunchAgents/${PORTAL_CANDIDATE_LABEL}.plist"
|
||||
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"
|
||||
@@ -260,8 +269,26 @@ candidate_healthy() {
|
||||
return 1
|
||||
}
|
||||
|
||||
deepseek_compat_healthy() {
|
||||
curl -fsS --max-time 5 "http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health" \
|
||||
| /opt/homebrew/opt/node@24/bin/node --input-type=module -e '
|
||||
let input = "";
|
||||
process.stdin.on("data", (chunk) => { input += chunk; });
|
||||
process.stdin.on("end", () => {
|
||||
const status = JSON.parse(input);
|
||||
if (
|
||||
status.ok !== true
|
||||
|| status.contractVersion !== 1
|
||||
|| status.deepseekThinking !== "disabled"
|
||||
) process.exit(1);
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
edge_ssh() {
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" "$@"
|
||||
# This remote release script itself arrives on stdin. Ordinary nested SSH
|
||||
# commands must not consume the unparsed remainder of that script.
|
||||
ssh -n -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" "$@"
|
||||
}
|
||||
|
||||
write_canary_tunnel_plist() {
|
||||
@@ -324,6 +351,7 @@ stop_candidate_services() {
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true
|
||||
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
|
||||
}
|
||||
|
||||
@@ -403,7 +431,8 @@ tar -xzf "${BUNDLE}" -C "${CANDIDATE_DIR}"
|
||||
cp "${MANIFEST}" "${CANDIDATE_DIR}/.release-manifest.txt"
|
||||
chmod 755 \
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-portal-candidate.sh" \
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh"
|
||||
"${CANDIDATE_DIR}/scripts/run-memind-canary-proxy-prod.sh" \
|
||||
"${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh"
|
||||
|
||||
say "Stop an older canary without touching stable Portal 8081"
|
||||
restore_edge_to_stable
|
||||
@@ -432,6 +461,39 @@ done
|
||||
"${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"
|
||||
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">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key><string>${DEEPSEEK_COMPAT_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array><string>${CANDIDATE_DIR}/scripts/run-deepseek-compat-proxy-candidate.sh</string></array>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>MEMIND_CANARY_STABLE_ROOT</key><string>${STABLE_DIR}</string>
|
||||
<key>MEMIND_CANARY_DEEPSEEK_PROXY_PORT</key><string>${DEEPSEEK_COMPAT_PORT}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
<key>ThrottleInterval</key><integer>5</integer>
|
||||
<key>StandardOutPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
|
||||
<key>StandardErrorPath</key><string>${HOME}/Library/Logs/memind-deepseek-compat-candidate.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
launchctl bootstrap "${LAUNCHD_GUI}" "${DEEPSEEK_COMPAT_PLIST}" >/dev/null
|
||||
launchctl kickstart -k "${LAUNCHD_GUI}/${DEEPSEEK_COMPAT_LABEL}" >/dev/null
|
||||
for _ in $(seq 1 30); do
|
||||
deepseek_compat_healthy && break
|
||||
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\"'"
|
||||
|
||||
say "Start the passive candidate Portal on 18081"
|
||||
cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@@ -447,6 +509,7 @@ cat > "${CANDIDATE_PLIST}" <<EOF
|
||||
<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>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
@@ -498,6 +561,7 @@ cat > "${PROXY_PLIST}" <<EOF
|
||||
<key>MEMIND_CANARY_PROXY_PORT</key><string>${CANARY_PROXY_PORT}</string>
|
||||
<key>MEMIND_RELEASE_CANARY_USERNAMES</key><string>${CANARY_USERNAMES}</string>
|
||||
<key>MEMIND_RELEASE_CANARY_WECHAT_USER_IDS</key><string>${CANARY_WECHAT_USER_IDS}</string>
|
||||
<key>MEMIND_CANARY_CANDIDATE_HEALTH_URLS</key><string>http://127.0.0.1:${DEEPSEEK_COMPAT_PORT}/health</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key><true/>
|
||||
<key>KeepAlive</key><true/>
|
||||
@@ -586,7 +650,9 @@ done
|
||||
[[ "${counts:-unknown unknown}" == "0 0" ]]
|
||||
|
||||
say "Switch the committed 105 nginx upstreams to the isolated canary tunnel"
|
||||
edge_ssh /bin/bash <<EDGE_SWITCH
|
||||
# Unlike ordinary edge_ssh calls, this command intentionally receives only the
|
||||
# bounded EDGE_SWITCH heredoc on stdin.
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 "${EDGE_HOST}" /bin/bash <<EDGE_SWITCH
|
||||
set -euo pipefail
|
||||
for config in '${EDGE_MOBILE_CONFIG}' '${EDGE_WECHAT_CONFIG}'; do
|
||||
before_count="\$(grep -cF 'proxy_pass http://58.38.22.103:8081;' "\${config}")"
|
||||
@@ -671,6 +737,7 @@ printf 'proxy_health=http://127.0.0.1:%s/__memind_canary/health\n' "${CANARY_PRO
|
||||
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 'deepseek_compat_health=http://127.0.0.1:%s/health\n' "${DEEPSEEK_COMPAT_PORT}"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
say "103 canary release completed"
|
||||
|
||||
@@ -84,10 +84,8 @@ if [[ "${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}" == "1" ]]; then
|
||||
fi
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||
echo "当前脚本执行 8081 整包晋升,不是用户级灰度入口。" >&2
|
||||
echo "在同一候选完成 103 灰度验收且晋升证据校验落地前,禁止非 dry-run。" >&2
|
||||
echo "首次生产动作请使用 scripts/release-portal-canary-prod.sh。" >&2
|
||||
exit 1
|
||||
say "验证 103 灰度晋升证据"
|
||||
node "${ROOT}/scripts/verify-canary-promotion-evidence.mjs" --host "${HOST}"
|
||||
fi
|
||||
|
||||
say() {
|
||||
@@ -166,7 +164,7 @@ fi
|
||||
|
||||
verify_runtime_artifact() {
|
||||
local missing=0
|
||||
for required in server.mjs memind-canary-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
for required in server.mjs memind-canary-proxy.mjs deepseek-no-think-proxy.mjs wechat-mp.bundle.mjs mindspace-sandbox-mcp.mjs tkmind-search-mcp.mjs tkmind-excel-mcp.mjs mindspace-public-links.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/run-memind-portal-candidate.sh scripts/run-memind-canary-proxy-prod.sh scripts/run-deepseek-compat-proxy-candidate.sh scripts/goosed-canary.compose.yml scripts/check-mindspace-public-links.mjs scripts/load-env.mjs scripts/wechat-mp-menu.mjs scripts/memind-portal-tunnel.sh; do
|
||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||
missing=1
|
||||
@@ -388,6 +386,7 @@ say "在 103 停旧服务并切换到无源码 runtime"
|
||||
ssh -o BatchMode=yes "${HOST}" \
|
||||
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' PORTAL_LABEL='${PORTAL_LABEL}' PORTAL_TUNNEL_LABEL='${PORTAL_TUNNEL_LABEL}' MEMIND_PORTAL_TUNNEL_HOST='${MEMIND_PORTAL_TUNNEL_HOST}' MEMIND_PORTAL_TUNNEL_REMOTE_PORT='${MEMIND_PORTAL_TUNNEL_REMOTE_PORT}' LAUNCHD_GUI='${LAUNCHD_GUI}' ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES='${ALLOW_MINDSPACE_PUBLIC_LINK_ISSUES:-0}' /bin/bash" <<'REMOTE_SCRIPT'
|
||||
set -euo pipefail
|
||||
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
|
||||
|
||||
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
|
||||
OLD_LIVE_DIR="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}"
|
||||
@@ -422,7 +421,7 @@ remount_goosed_after_live_swap() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
for _ in $(seq 1 120); do
|
||||
local healthy=1
|
||||
local containers=()
|
||||
while IFS= read -r container; do
|
||||
@@ -495,6 +494,11 @@ block_new_agent_runs_for_release() {
|
||||
touch "${APP_DIR}/.release-drain"
|
||||
}
|
||||
|
||||
read_agent_run_status_json() {
|
||||
local worker_script="$1"
|
||||
node "${worker_script}" --status 2>/dev/null | sed -n '/^{/,$p' || true
|
||||
}
|
||||
|
||||
drain_active_agent_runs() {
|
||||
local deadline=$(( $(date +%s) + ${MEMIND_RELEASE_DRAIN_TIMEOUT_SEC:-120} ))
|
||||
local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs"
|
||||
@@ -504,7 +508,7 @@ drain_active_agent_runs() {
|
||||
return 1
|
||||
fi
|
||||
local status_json
|
||||
status_json="$(node "${worker_script}" --status 2>/dev/null || true)"
|
||||
status_json="$(read_agent_run_status_json "${worker_script}")"
|
||||
if [[ -n "${status_json}" ]]; then
|
||||
local in_flight pending
|
||||
in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')"
|
||||
@@ -523,7 +527,7 @@ verify_no_active_agent_runs_before_swap() {
|
||||
local worker_script="${APP_DIR}/scripts/agent-run-worker.mjs"
|
||||
[[ -f "${worker_script}" ]] || return 1
|
||||
local status_json in_flight pending
|
||||
status_json="$(node "${worker_script}" --status 2>/dev/null || true)"
|
||||
status_json="$(read_agent_run_status_json "${worker_script}")"
|
||||
in_flight="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.inFlight||0)); } catch { console.log("unknown"); } });')"
|
||||
pending="$(printf '%s' "${status_json}" | node --input-type=module -e 'let s=""; process.stdin.on("data", c => s += c); process.stdin.on("end", () => { try { const q=JSON.parse(s).queue||{}; console.log(Number(q.pendingDispatches||0)); } catch { console.log("unknown"); } });')"
|
||||
[[ "${in_flight}" == "0" && "${pending}" == "0" ]]
|
||||
@@ -652,8 +656,18 @@ copy_persisted_item() {
|
||||
}
|
||||
|
||||
say "备份当前 live 全目录"
|
||||
set +e
|
||||
COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' \
|
||||
-czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
|
||||
tar_status=$?
|
||||
set -e
|
||||
if [[ ! -s "${FULL_BACKUP_TAR}" ]]; then
|
||||
echo "full backup failed: ${FULL_BACKUP_TAR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${tar_status}" -ne 0 ]]; then
|
||||
echo "warning: full backup tar reported errors; continuing with partial archive" >&2
|
||||
fi
|
||||
|
||||
say "单独备份持久目录"
|
||||
tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-persisted.XXXXXX")"
|
||||
|
||||
@@ -6,6 +6,7 @@ STABLE_DIR="${STUDIO_REMOTE_ROOT:-/Users/john/Project}/Memind"
|
||||
PORTAL_CANDIDATE_LABEL="cn.tkmind.memind-portal-candidate"
|
||||
CANARY_PROXY_LABEL="cn.tkmind.memind-canary-proxy"
|
||||
CANARY_TUNNEL_LABEL="cn.tkmind.memind-canary-tunnel"
|
||||
DEEPSEEK_COMPAT_LABEL="cn.tkmind.memind-deepseek-compat-candidate"
|
||||
EDGE_HOST="${MEMIND_CANARY_EDGE_HOST:-ssh105-public}"
|
||||
CANARY_TUNNEL_REMOTE_PORT=19082
|
||||
EDGE_MOBILE_CONFIG="/etc/nginx/conf.d/m.tkmind.cn.conf"
|
||||
@@ -30,6 +31,7 @@ ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
|
||||
PORTAL_CANDIDATE_LABEL='${PORTAL_CANDIDATE_LABEL}' \
|
||||
CANARY_PROXY_LABEL='${CANARY_PROXY_LABEL}' \
|
||||
CANARY_TUNNEL_LABEL='${CANARY_TUNNEL_LABEL}' \
|
||||
DEEPSEEK_COMPAT_LABEL='${DEEPSEEK_COMPAT_LABEL}' \
|
||||
EDGE_HOST='${EDGE_HOST}' \
|
||||
CANARY_TUNNEL_REMOTE_PORT='${CANARY_TUNNEL_REMOTE_PORT}' \
|
||||
EDGE_MOBILE_CONFIG='${EDGE_MOBILE_CONFIG}' \
|
||||
@@ -69,6 +71,7 @@ EDGE_ROLLBACK
|
||||
launchctl bootout "${LAUNCHD_GUI}/${CANARY_TUNNEL_LABEL}" >/dev/null 2>&1 || true
|
||||
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"
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
STABLE_ROOT="${MEMIND_CANARY_STABLE_ROOT:-/Users/john/Project/Memind}"
|
||||
|
||||
if [[ ! -d "${STABLE_ROOT}" || ! -f "${STABLE_ROOT}/.env" ]]; then
|
||||
echo "[deepseek-compat] stable runtime environment is unavailable: ${STABLE_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${STABLE_ROOT}/.env"
|
||||
set +a
|
||||
|
||||
export NODE_ENV=production
|
||||
export MEMIND_DEEPSEEK_PROXY_ENTRYPOINT=1
|
||||
export MEMIND_DEEPSEEK_NO_THINK_PORT="${MEMIND_CANARY_DEEPSEEK_PROXY_PORT:-18036}"
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
exec "${NODE_BIN}" "${ROOT}/deepseek-no-think-proxy.mjs"
|
||||
@@ -9,6 +9,8 @@ if [[ ! -d "${STABLE_ROOT}" || ! -f "${STABLE_ROOT}/.env" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source "${STABLE_ROOT}/.env"
|
||||
@@ -27,6 +29,17 @@ 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
|
||||
# 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
|
||||
unset MEMIND_DEEPSEEK_NO_THINK_BASE_URL
|
||||
unset MEMIND_DEEPSEEK_NO_THINK_HOST
|
||||
unset TKMIND_API_TARGET_1
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
const ROOT = path.resolve(new URL('..', import.meta.url).pathname);
|
||||
const REMOTE_ROOT = process.env.STUDIO_REMOTE_ROOT ?? '/Users/john/Project';
|
||||
const CANDIDATE_BASE = `${REMOTE_ROOT}/Memind-candidates`;
|
||||
|
||||
const REMOTE_SCRIPT = [
|
||||
'set -euo pipefail',
|
||||
'expected_commit="$1"',
|
||||
'candidate_base="$2"',
|
||||
'matched=""',
|
||||
'latest=""',
|
||||
'for dir in "${candidate_base}"/*; do',
|
||||
' [[ -d "${dir}" ]] || continue',
|
||||
' [[ -f "${dir}/.release-manifest.txt" ]] || continue',
|
||||
' if [[ -z "${latest}" || "$(basename "${dir}")" > "$(basename "${latest}")" ]]; then',
|
||||
' latest="${dir}"',
|
||||
' fi',
|
||||
' if grep -q "^git_head=${expected_commit}$" "${dir}/.release-manifest.txt"; then',
|
||||
' matched="${dir}"',
|
||||
' fi',
|
||||
'done',
|
||||
'if [[ -n "${matched}" ]]; then candidate="${matched}"; else candidate="${latest}"; fi',
|
||||
'[[ -n "${candidate}" ]] || { echo "NO_CANDIDATE"; exit 1; }',
|
||||
'printf \'candidate_dir=%s\\n\' "${candidate}"',
|
||||
'cat "${candidate}/.release-manifest.txt"',
|
||||
'printf \'\\n---HEALTH---\\n\'',
|
||||
'curl -s -D - -o /dev/null http://127.0.0.1:18081/api/status | tr -d \'\\r\'',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
host: process.env.STUDIO_HOST ?? '58.38.22.103',
|
||||
commit: null,
|
||||
};
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--host') options.host = argv[++index] ?? '';
|
||||
else if (arg === '--commit') options.commit = argv[++index] ?? '';
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function git(...args) {
|
||||
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function ssh(host, args, input = '') {
|
||||
const result = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, ...args], {
|
||||
encoding: 'utf8',
|
||||
input,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`SSH to ${host} failed:\n${result.stderr || result.stdout}`);
|
||||
}
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function parseManifest(content) {
|
||||
const fields = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
fields[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
try {
|
||||
const options = parseArgs(process.argv);
|
||||
const commit = options.commit ?? git('rev-parse', 'HEAD');
|
||||
const remoteUserHost = options.host.includes('@') ? options.host : `john@${options.host}`;
|
||||
|
||||
const remoteOutput = ssh(
|
||||
remoteUserHost,
|
||||
['/bin/bash', '-s', '--', commit, CANDIDATE_BASE],
|
||||
REMOTE_SCRIPT,
|
||||
);
|
||||
|
||||
const healthMarker = '\n---HEALTH---\n';
|
||||
const markerIndex = remoteOutput.indexOf('---HEALTH---');
|
||||
if (markerIndex < 0) {
|
||||
throw new Error('Canary promotion evidence response missing health probe');
|
||||
}
|
||||
const manifestText = remoteOutput.slice(0, markerIndex);
|
||||
const healthText = remoteOutput.slice(markerIndex + healthMarker.trim().length);
|
||||
|
||||
const candidateDir = manifestText
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('candidate_dir='))
|
||||
?.slice('candidate_dir='.length);
|
||||
const manifest = parseManifest(
|
||||
manifestText
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('candidate_dir='))
|
||||
.join('\n'),
|
||||
);
|
||||
|
||||
if (!candidateDir) {
|
||||
throw new Error('103 has no canary candidate directory');
|
||||
}
|
||||
if (manifest.git_head !== commit) {
|
||||
throw new Error(
|
||||
`Canary candidate commit mismatch: candidate=${manifest.git_head ?? 'missing'} expected=${commit}`,
|
||||
);
|
||||
}
|
||||
if (!/^HTTP\/1\.1 200/m.test(healthText)) {
|
||||
throw new Error('Canary candidate /api/status is not healthy on 18081');
|
||||
}
|
||||
if (!/^x-memind-runtime-role: candidate/im.test(healthText)) {
|
||||
throw new Error('Canary candidate health probe missing X-Memind-Runtime-Role: candidate');
|
||||
}
|
||||
|
||||
console.log('Canary promotion evidence verified');
|
||||
console.log(`candidate_dir=${candidateDir}`);
|
||||
console.log(`release_id=${manifest.release_id ?? 'unknown'}`);
|
||||
console.log(`git_head=${manifest.git_head}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user