Compare commits

...

6 Commits

Author SHA1 Message Date
john bfb1f6fea9 fix(memory): initialize candidates and enforce lifecycle rollout
Memind CI / Test, build, and release guards (push) Successful in 3m29s
2026-07-21 22:55:19 +08:00
tkmind 0dd9331e5b merge: verify MindSpace service replacement
Memind CI / Test, build, and release guards (push) Successful in 2m15s
CI run 1065 passed. Fixes the 8082 listener race and validates the replacement launchd job.
2026-07-21 10:59:04 +00:00
john 4e6db07123 fix: verify MindSpace service replacement
Memind CI / Test, build, and release guards (pull_request) Successful in 2m26s
2026-07-21 18:55:23 +08:00
tkmind fe4b8c0da9 merge: preserve MindSpace runtime data during release
Memind CI / Test, build, and release guards (push) Successful in 2m30s
CI run 1063 passed. Required release safety fix for the image_make production canary.
2026-07-21 10:41:21 +00:00
john fa777fa195 fix: preserve MindSpace runtime data during release
Memind CI / Test, build, and release guards (pull_request) Successful in 2m31s
2026-07-21 18:37:49 +08:00
tkmind b63c906c23 merge: complete reviewed image generation delivery
Memind CI / Test, build, and release guards (push) Successful in 2m57s
Merge validated 0717-dev image generation delivery changes. No 103 runtime artifact or production deployment.
2026-07-20 12:09:06 +00:00
10 changed files with 298 additions and 19 deletions
+1
View File
@@ -30,6 +30,7 @@ bash scripts/check-release-ready.sh
| MindSpace remote 页面 sync + storage 缺失缩略图 fallback | [docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md](docs/regression-guards/mindspace-remote-page-sync-and-thumbnail.md) |
| Page Data 数据集注册、绑定与交付验收 | [docs/regression-guards/page-data-delivery-contract.md](docs/regression-guards/page-data-delivery-contract.md) |
| H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) |
| Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) |
索引:[docs/regression-guards/README.md](docs/regression-guards/README.md)
+10 -1
View File
@@ -79,7 +79,16 @@ The additive user-scoped management API is:
Lifecycle workers are disabled by default. When explicitly enabled they run
expiration, conservative compaction observation, candidate promotion, and
reflection observation according to the rollout mode; none of these operations
blocks the chat path.
blocks the chat path. `off` creates no worker scope and performs no lifecycle
mutation, `canary` is always user-scoped to the configured rollout IDs, and only
`active` permits an unscoped global worker run.
When candidate persistence is enabled, the Portal runtime idempotently creates
`h5_memory_v2_candidates` before enabling the MySQL candidate store. DDL failure
is fail-open for Portal chat and falls back to bounded in-memory candidates;
`memind_adm` uses the same schema helper during bootstrap and fails startup rather
than serving a permanently broken candidate API. The table is additive and must
not be dropped as part of an application rollback.
The pgvector adapter does not create tables or generate embeddings. It only defines the adapter contract for a future semantic memory backend and requires explicit `enabled: true`, an injected PostgreSQL pool, and either an input embedding or an injected `embedQuery(...)` function.
+1
View File
@@ -10,6 +10,7 @@
| [mindspace-remote-page-sync-and-thumbnail.md](./mindspace-remote-page-sync-and-thumbnail.md) | ① remote 模式 public HTML 入库 sync ② storage 缺失时缩略图/读页回退 workspace HTML |
| [page-data-delivery-contract.md](./page-data-delivery-contract.md) | 数据集注册、绑定、真实 page UUID 与交付验收 |
| [h5-session-stream-replay.md](./h5-session-stream-replay.md) | Portal/Goose SSE 游标映射、断线续播与 Finish 终态恢复 |
| [memory-v2-candidate-and-lifecycle.md](./memory-v2-candidate-and-lifecycle.md) | 候选记忆表幂等初始化、Portal fail-open、生命周期 off/canary/active 作用域 |
## 自动化
@@ -0,0 +1,45 @@
# Memory V2 候选表与生命周期灰度守卫
## 已知故障
生产曾出现 `h5_memory_v2_candidates` 不存在:候选记忆写入和后台查询报错,
但 Agent 召回仍在 shadow 模式执行,因此表现为“有召回事件、回答未注入、个人记忆状态降级”。
同时,生命周期 worker 只检查了 worker 开关;`expire()` 没有检查 rollout
作用域。若忘却开关开启,即使 `MEMORY_LIFECYCLE_ROLLOUT_MODE=off`,定时器也可能
归档全量用户的过期记忆。
## 必须保留的行为
1. `MEMORY_CANDIDATE_PERSISTENCE_ENABLED=1` 且 MySQL 可用时,Portal 必须先执行
`ensurePersonalMemoryCandidateSchema()`,再创建候选记忆 store。
2. 候选表 DDL 失败不能阻塞 Portal 聊天启动;Portal 应记录告警并退回 bounded-memory。
3. memind_adm 必须在创建候选 store 前完成同一幂等建表;初始化失败时后台启动失败,
不允许以“页面可用但候选接口持续报错”的半初始化状态运行。
4. 生命周期 rollout 语义必须严格一致:
- `off`:不得执行任何写操作,也不得启动 worker 定时器。
- `canary`:只允许 `MEMORY_LIFECYCLE_ROLLOUT_USER_IDS` 中的用户。
- `active`:才允许无 user scope 的全局任务。
5. `forgetMemory()``expire()` 都必须同时满足功能开关与 rollout 作用域。
## 回归检查
```bash
node --test memory-v2-personal-store.test.mjs memory-v2-lifecycle.test.mjs memory-v2-runtime.test.mjs
npm test
```
memind_adm 同时执行:
```bash
node --test server/personal-memory-candidate-store.test.mjs
npm test
```
灰度上线后还必须验证:候选表存在、候选写入成功、后台可查询,以及一个测试用户完成
“保存 → 候选 → 晋升 → 新会话召回 → 回答注入”的闭环。未通过前不得切换全量 active。
## 回滚
代码可以按标准 runtime/release 回滚;候选表是加法结构,回滚时保留空表或已有数据,
不得为了代码回滚直接删除表。先把候选、生命周期和 Agent 注入模式切回 shadow/off。
+13 -2
View File
@@ -33,6 +33,15 @@ export function resolveMemoryV2LifecyclePolicy(env = process.env) {
};
}
export function resolveMemoryV2LifecycleWorkerScopes(policy = {}) {
if (!policy.enabled) return [];
if (policy.rolloutMode === 'active') return [null];
if (policy.rolloutMode === 'canary') {
return [...new Set((policy.rolloutUserIds ?? []).map(String).filter(Boolean))];
}
return [];
}
function normalizeItem(row) {
return {
id: String(row.id),
@@ -71,7 +80,7 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
}
async function forgetMemory({ userId, memoryId } = {}) {
if (!canRun(userId) && !policy.forgettingEnabled) return { ok: true, updated: false, skipped: true, reason: 'disabled' };
if (!policy.forgettingEnabled || !canRun(userId)) return { ok: true, updated: false, skipped: true, reason: 'disabled' };
if (!pool?.query || !userId || !memoryId) return { ok: false, updated: false, reason: 'invalid_input' };
const [result] = await pool.query(
`UPDATE ${MEMORY_TABLE} SET status = 'deleted', updated_at = ? WHERE id = ? AND user_id = ? AND status <> 'deleted'`,
@@ -82,7 +91,9 @@ export function createMemoryV2LifecycleService({ pool = null, env = process.env,
}
async function expire({ userId = null } = {}) {
if (!pool?.query || !policy.forgettingEnabled) return { ok: true, skipped: true, reason: 'disabled', expired: 0 };
if (!pool?.query || !policy.forgettingEnabled || !canRun(userId)) {
return { ok: true, skipped: true, reason: 'disabled', expired: 0 };
}
const cutoff = now() - policy.retentionDays * 86400000;
const params = [cutoff];
let scope = '';
+52 -2
View File
@@ -1,6 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMemoryV2LifecycleService, resolveMemoryV2LifecyclePolicy } from './memory-v2-lifecycle.mjs';
import {
createMemoryV2LifecycleService,
resolveMemoryV2LifecyclePolicy,
resolveMemoryV2LifecycleWorkerScopes,
} from './memory-v2-lifecycle.mjs';
test('lifecycle policy defaults to safe disabled workers', () => {
const policy = resolveMemoryV2LifecyclePolicy({});
@@ -25,6 +29,16 @@ test('lifecycle canary only runs for configured users', async () => {
assert.equal((await lifecycle.promote({ userId: 'u-2' })).skipped, true);
});
test('lifecycle worker scopes match off, canary, and active rollout modes', () => {
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({ enabled: true, rolloutMode: 'off' }), []);
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({
enabled: true,
rolloutMode: 'canary',
rolloutUserIds: ['u-1', 'u-1', 'u-2'],
}), ['u-1', 'u-2']);
assert.deepEqual(resolveMemoryV2LifecycleWorkerScopes({ enabled: true, rolloutMode: 'active' }), [null]);
});
test('forget is fail-safe when lifecycle is disabled', async () => {
const lifecycle = createMemoryV2LifecycleService({ env: { MEMORY_LIFECYCLE_ENABLED: '0' } });
assert.deepEqual(await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' }), {
@@ -39,9 +53,45 @@ test('list and forget use user-scoped SQL', async () => {
if (sql.startsWith('SELECT')) return [[{ id: 'm-1', user_id: 'u-1', label: 'fact', memory_text: 'x', status: 'active', confidence: 0.9, created_at: 1, updated_at: 2 }]];
return [{ affectedRows: 1 }];
} };
const lifecycle = createMemoryV2LifecycleService({ pool, env: { MEMORY_LIFECYCLE_ENABLED: '1', MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1' } });
const lifecycle = createMemoryV2LifecycleService({ pool, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'active',
} });
assert.equal((await lifecycle.listMemories({ userId: 'u-1' }))[0].id, 'm-1');
assert.equal((await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' })).updated, true);
assert.match(calls[0].sql, /user_id = \?/);
assert.match(calls[1].sql, /user_id = \?/);
});
test('forget and expire never mutate data while lifecycle rollout is off', async () => {
const calls = [];
const pool = { query: async (...args) => { calls.push(args); return [{ affectedRows: 1 }]; } };
const lifecycle = createMemoryV2LifecycleService({ pool, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'off',
} });
assert.equal((await lifecycle.forgetMemory({ userId: 'u-1', memoryId: 'm-1' })).skipped, true);
assert.equal((await lifecycle.expire()).skipped, true);
assert.equal(calls.length, 0);
});
test('expire is limited to the configured canary user', async () => {
const calls = [];
const pool = { query: async (sql, params) => { calls.push({ sql, params }); return [{ affectedRows: 2 }]; } };
const lifecycle = createMemoryV2LifecycleService({ pool, now: () => 1_000_000, env: {
MEMORY_LIFECYCLE_ENABLED: '1',
MEMORY_LIFECYCLE_FORGETTING_ENABLED: '1',
MEMORY_LIFECYCLE_ROLLOUT_MODE: 'canary',
MEMORY_LIFECYCLE_ROLLOUT_USER_IDS: 'u-1',
MEMORY_POLICY_RETENTION_DAYS: '1',
} });
assert.equal((await lifecycle.expire({ userId: 'u-2' })).skipped, true);
assert.equal((await lifecycle.expire({ userId: 'u-1' })).expired, 2);
assert.equal(calls.length, 1);
assert.match(calls[0].sql, /user_id = \?/);
assert.equal(calls[0].params.at(-1), 'u-1');
});
+34 -9
View File
@@ -5,7 +5,10 @@ import { createLegacyMemoryBackend, createMemoryV2, resolveMemoryV2Policy } from
import { createLettaHttpClient, createLettaMemoryBackend } from './memory-v2-letta.mjs';
import { createMemoryV2PluginBackends } from './memory-v2-plugin-backends.mjs';
import { createPersonalMemoryShadowPipeline } from './memory-v2-personal-shadow.mjs';
import { createPersonalMemoryCandidateStore } from './memory-v2-personal-store.mjs';
import {
createPersonalMemoryCandidateStore,
ensurePersonalMemoryCandidateSchema,
} from './memory-v2-personal-store.mjs';
import { createMem0HttpClient, createMem0MemoryBackend } from './memory-v2-mem0.mjs';
import { createNeo4jHttpClient, createNeo4jMemoryBackend } from './memory-v2-neo4j.mjs';
import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs';
@@ -13,7 +16,10 @@ import { backfillLegacyMemoriesToPgvector } from './memory-v2-pgvector-backfill.
import { createQdrantHttpClient, createQdrantMemoryBackend } from './memory-v2-qdrant.mjs';
import { createRedisStreamsClient, createRedisStreamsMemoryBackend } from './memory-v2-redis-streams.mjs';
import { createWeaviateHttpClient, createWeaviateMemoryBackend } from './memory-v2-weaviate.mjs';
import { createMemoryV2LifecycleService } from './memory-v2-lifecycle.mjs';
import {
createMemoryV2LifecycleService,
resolveMemoryV2LifecycleWorkerScopes,
} from './memory-v2-lifecycle.mjs';
const DEFAULT_PGVECTOR_URL_ENV = 'MEMORY_PGVECTOR_DATABASE_URL';
@@ -403,9 +409,17 @@ export async function createMemoryV2Runtime({
],
}));
const personalCandidateStore = readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false)
? createPersonalMemoryCandidateStore(mysqlPool)
: null;
let personalCandidateStore = null;
if (readFlag(env, 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', false) && mysqlPool?.query) {
try {
await ensurePersonalMemoryCandidateSchema(mysqlPool);
personalCandidateStore = createPersonalMemoryCandidateStore(mysqlPool);
} catch (err) {
logger?.warn?.(
`[memory-v2] candidate persistence unavailable: ${err instanceof Error ? err.message : err}`,
);
}
}
const personalShadowPipeline = createPersonalMemoryShadowPipeline({
env,
store: personalCandidateStore,
@@ -420,11 +434,22 @@ export async function createMemoryV2Runtime({
const lifecycle = createMemoryV2LifecycleService({ pool: mysqlPool, env, logger });
let lifecycleTimer = null;
const lifecycleWorkerEnabled = readFlag(env, 'MEMORY_LIFECYCLE_WORKER_ENABLED', false);
if (lifecycleWorkerEnabled && mysqlPool?.query) {
const lifecycleWorkerScopes = resolveMemoryV2LifecycleWorkerScopes(lifecycle.policy);
if (lifecycleWorkerEnabled && mysqlPool?.query && lifecycleWorkerScopes.length > 0) {
const intervalMs = Math.max(60_000, Number(lifecycle.policy.compactIntervalHours ?? 24) * 3_600_000);
const runLifecycle = () => lifecycle.expire().then(() => lifecycle.compact()).then(() => lifecycle.promote()).then(() => lifecycle.reflect()).catch((err) => {
logger?.warn?.(`[memory-v2] lifecycle worker skipped: ${err instanceof Error ? err.message : err}`);
});
const runLifecycle = async () => {
try {
for (const userId of lifecycleWorkerScopes) {
const input = userId == null ? {} : { userId };
await lifecycle.expire(input);
await lifecycle.compact(input);
await lifecycle.promote(input);
await lifecycle.reflect(input);
}
} catch (err) {
logger?.warn?.(`[memory-v2] lifecycle worker skipped: ${err instanceof Error ? err.message : err}`);
}
};
lifecycleTimer = setInterval(runLifecycle, intervalMs);
lifecycleTimer.unref?.();
}
+51
View File
@@ -76,6 +76,57 @@ test('createMemoryV2Runtime does not open pg pool when embedding module is missi
assert.equal(pgvector.reason, 'embedding_module_not_configured');
});
test('createMemoryV2Runtime ensures candidate schema before enabling MySQL persistence', async () => {
const calls = [];
const mysqlPool = {
async query(sql, params = []) {
calls.push({ sql, params });
if (sql.startsWith('CREATE TABLE')) return [[], []];
if (sql.startsWith('INSERT')) return [{ affectedRows: 1 }, []];
throw new Error(`Unexpected SQL: ${sql}`);
},
};
const memory = await createMemoryV2Runtime({
logger: silentLogger(),
legacyMemoryService: legacyService(),
mysqlPool,
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
},
});
assert.equal(memory.getStatus().personalMemory.persistence, 'mysql');
assert.match(calls[0].sql, /CREATE TABLE IF NOT EXISTS `h5_memory_v2_candidates`/);
const observed = await memory.observePersonalMemory({
userId: 'u-1',
sessionId: 's-1',
messages: [{ role: 'user', content: '请记住我偏好使用简洁的中文回答' }],
});
assert.equal(observed.accepted, 1);
assert.equal(calls[1].sql.startsWith('INSERT'), true);
await memory.close();
});
test('createMemoryV2Runtime fails open when candidate schema initialization fails', async () => {
const logger = silentLogger();
const memory = await createMemoryV2Runtime({
logger,
legacyMemoryService: legacyService(),
mysqlPool: { async query() { throw new Error('ddl denied'); } },
env: {
MEMORY_CANDIDATE_ENABLED: '1',
MEMORY_CANDIDATE_MODE: 'shadow',
MEMORY_CANDIDATE_PERSISTENCE_ENABLED: '1',
},
});
assert.equal(memory.getStatus().personalMemory.persistence, 'bounded-memory');
assert.match(logger.warnings[0], /candidate persistence unavailable: ddl denied/);
await memory.close();
});
test('createMemoryV2Runtime selects pgvector only when pool and embedding are configured', async () => {
const queries = [];
let poolEnded = false;
+44
View File
@@ -6,6 +6,10 @@ const releaseScript = fs.readFileSync(
new URL('./scripts/release-portal-runtime-prod.sh', import.meta.url),
'utf8',
);
const mindSpaceReleaseScript = fs.readFileSync(
new URL('./scripts/release-mindspace-service-prod.sh', import.meta.url),
'utf8',
);
test('103 release remounts goosed after swapping the live directory', () => {
assert.match(releaseScript, /remount_goosed_after_live_swap\(\)/);
@@ -23,3 +27,43 @@ test('103 release remounts goosed after swapping the live directory', () => {
assert.ok(remountIndex > swapIndex, 'goosed remount must happen after the live swap');
assert.ok(portalStartIndex > remountIndex, 'Portal must start only after goosed remount succeeds');
});
test('MindSpace release preserves runtime data and returns it during rollback', () => {
assert.match(mindSpaceReleaseScript, /PERSISTED_DATA_MOVED=0/);
assert.match(mindSpaceReleaseScript, /PERSISTED_USERS_MOVED=0/);
assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/data" "\$\{APP_DIR\}\/data"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{OLD_APP_ARCHIVE\}\/users" "\$\{APP_DIR\}\/users"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/data" "\$\{OLD_APP_ARCHIVE\}\/data"/);
assert.match(mindSpaceReleaseScript, /mv "\$\{APP_DIR\}\/users" "\$\{OLD_APP_ARCHIVE\}\/users"/);
});
test('MindSpace release waits for the old listener and verifies the new launchd job', () => {
const bootoutIndex = mindSpaceReleaseScript.indexOf(
'launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}"',
);
const listenerWaitIndex = mindSpaceReleaseScript.indexOf('lsof -nP -iTCP:8082', bootoutIndex);
const swapIndex = mindSpaceReleaseScript.indexOf('mv "${NEW_DIR}" "${APP_DIR}"');
const bootstrapIndex = mindSpaceReleaseScript.indexOf(
'launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}"',
swapIndex,
);
const portalRestartIndex = mindSpaceReleaseScript.indexOf(
'launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"',
bootstrapIndex,
);
const finalServiceCheckIndex = mindSpaceReleaseScript.indexOf('service_is_running', portalRestartIndex);
assert.ok(bootoutIndex >= 0, 'old MindSpace launchd job must be stopped');
assert.ok(listenerWaitIndex > bootoutIndex, 'release must wait for the old 8082 listener to exit');
assert.ok(swapIndex > listenerWaitIndex, 'runtime swap must happen after the old listener exits');
assert.ok(bootstrapIndex > swapIndex, 'new launchd job must start after the runtime swap');
assert.doesNotMatch(
mindSpaceReleaseScript.slice(bootstrapIndex, bootstrapIndex + 120),
/\|\| true/,
'launchctl bootstrap failures must not be ignored',
);
assert.ok(
finalServiceCheckIndex > portalRestartIndex,
'MindSpace launchd state must be rechecked after Portal restarts',
);
});
+47 -5
View File
@@ -196,11 +196,17 @@ SERVICE_STARTED=0
PORTAL_TOUCHED=0
APP_MOVED=0
HAD_OLD_APP=0
PERSISTED_DATA_MOVED=0
PERSISTED_USERS_MOVED=0
say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
service_is_running() {
launchctl print "${LAUNCHD_GUI}/${SERVICE_LABEL}" 2>/dev/null | grep -q 'state = running'
}
rollback() {
set +e
say "rollback: restoring previous state"
@@ -217,6 +223,16 @@ rollback() {
rm -f "${SERVICE_PLIST}"
fi
if [[ "${APP_MOVED}" == "1" ]]; then
if [[ "${HAD_OLD_APP}" == "1" ]]; then
if [[ "${PERSISTED_DATA_MOVED}" == "1" && ( -e "${APP_DIR}/data" || -L "${APP_DIR}/data" ) ]]; then
rm -rf "${OLD_APP_ARCHIVE}/data"
mv "${APP_DIR}/data" "${OLD_APP_ARCHIVE}/data"
fi
if [[ "${PERSISTED_USERS_MOVED}" == "1" && ( -e "${APP_DIR}/users" || -L "${APP_DIR}/users" ) ]]; then
rm -rf "${OLD_APP_ARCHIVE}/users"
mv "${APP_DIR}/users" "${OLD_APP_ARCHIVE}/users"
fi
fi
rm -rf "${APP_DIR}"
if [[ "${HAD_OLD_APP}" == "1" && -d "${OLD_APP_ARCHIVE}" ]]; then
mv "${OLD_APP_ARCHIVE}" "${APP_DIR}"
@@ -302,6 +318,16 @@ EOF
say "切换 MindSpace service live 目录"
launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
for _ in $(seq 1 60); do
if ! lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
sleep 1
done
if lsof -nP -iTCP:8082 -sTCP:LISTEN >/dev/null 2>&1; then
echo "旧 MindSpace service 未在超时内退出,拒绝切换 runtime" >&2
exit 1
fi
rm -rf "${OLD_APP_ARCHIVE}"
if [[ -d "${APP_DIR}" ]]; then
mv "${APP_DIR}" "${OLD_APP_ARCHIVE}"
@@ -309,21 +335,34 @@ fi
mv "${NEW_DIR}" "${APP_DIR}"
APP_MOVED=1
say "继承 MindSpace 持久目录"
if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/data" || -L "${OLD_APP_ARCHIVE}/data" ) ]]; then
rm -rf "${APP_DIR}/data"
mv "${OLD_APP_ARCHIVE}/data" "${APP_DIR}/data"
PERSISTED_DATA_MOVED=1
fi
if [[ "${HAD_OLD_APP}" == "1" && ( -e "${OLD_APP_ARCHIVE}/users" || -L "${OLD_APP_ARCHIVE}/users" ) ]]; then
rm -rf "${APP_DIR}/users"
mv "${OLD_APP_ARCHIVE}/users" "${APP_DIR}/users"
PERSISTED_USERS_MOVED=1
fi
say "启动 MindSpace service"
mkdir -p "${APP_DIR}/data/mindspace" "${APP_DIR}/users"
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true
launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null
SERVICE_STARTED=1
launchctl enable "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null
for _ in $(seq 1 30); do
code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)"
if [[ "${code}" == "200" ]]; then
if [[ "${code}" == "200" ]] && service_is_running; then
break
fi
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]]
set_env() {
@@ -371,6 +410,9 @@ for _ in $(seq 1 30); do
sleep 1
done
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]]
sleep 3
[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]]
service_is_running
trap - ERR
@@ -379,7 +421,7 @@ printf 'release_id=%s\n' "${RELEASE_ID}"
printf 'service_root=%s\n' "${APP_DIR}"
printf 'service_backup=%s\n' "${APP_BACKUP}"
printf 'env_backup=%s\n' "${ENV_BACKUP}"
printf 'service_health=%s\n' "$(curl -s http://127.0.0.1:8082/health)"
printf 'service_health=%s\n' "$(curl -fsS http://127.0.0.1:8082/health)"
REMOTE
say "发布完成"