diff --git a/.gitignore b/.gitignore index 04b6af0..5599414 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ ops/node_modules/ dist/ ops/dist/ +.runtime/ .env .env.local diff --git a/PRODUCTION_RELEASE_RULES.md b/PRODUCTION_RELEASE_RULES.md index 303bddc..4cf6e02 100644 --- a/PRODUCTION_RELEASE_RULES.md +++ b/PRODUCTION_RELEASE_RULES.md @@ -5,6 +5,7 @@ 1. `103` 是正式生产主机,`105` 是云侧入口/历史链路;**本机一律不允许直接 `rsync` 到 `103` 或 `105`**,也不允许在线改源码后继续运行。 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` 源码。 3. Portal 生产发布唯一合法路径是:本地已提交代码 -> 本机 `node scripts/build-portal-runtime.mjs` -> `bash scripts/release-portal-runtime-prod.sh` -> 上传 `103` -> 全量备份 + 持久目录备份 -> 原子切换 live 目录 -> 重启 Portal -> 健康检查。 4. `scripts/release-prod.sh`(源码包发布)已停用,不得再用于 Portal;`rsync_to_server.sh` 与任何面向 `105` 的直接同步脚本也只保留为禁用提示。 5. 发布包不得携带运行态资产;`.env`、`MindSpace/`、`data/`、`users/`、`.tailscale/`、`public/plaza-covers/`、`logs/` 只能从线上现有 live 目录继承。 diff --git a/docs/service-isolation-runbook.md b/docs/service-isolation-runbook.md index 90df665..495830d 100644 --- a/docs/service-isolation-runbook.md +++ b/docs/service-isolation-runbook.md @@ -9,6 +9,7 @@ | 环境 | 目录 | 用途 | 端口 | |------|------|------|------| | 生产 | `/Users/john/Project/Memind` | `g2.tkmind.cn` 当前在线服务(阿里云解析 → 105 → 本机 Mac) | `8081` | +| 生产 MindSpace Service | `/Users/john/MindSpace` | MindSpace 独立 RPC / agent / asset 服务,供 Portal remote adapter 调用 | `8082` | | 生产 Plaza | `/Users/john/Project/Memind` + Plaza | `plaza.tkmind.cn` 当前在线服务 | `3001` | | 生产入口 | `/Users/john/Project/Memind/scripts/g2-lb.Caddyfile` | 105 转发入口 / 反代配置 | `8090` | | 测试 Portal | `/Users/john/Project/Memind` | 开发预览 API / Portal | `18081` | @@ -20,6 +21,7 @@ 硬规则: - 不在开发预览中使用 `8081`、`3001`、`8090`。 +- 不在开发预览中使用生产 MindSpace service 的 `8082`。 - **禁止 SSH 到 `105` 直接改业务源码**;105 是入口/代理层,变更须本地 commit 后发布。见 [105 服务器变更规范](./105-server-operations.md)。 - 不在生产目录里跑会清理端口的开发脚本。 - 不执行 `scripts/install-prod-services.sh` 来做开发预览;它会释放生产端口。 diff --git a/mindspace-service/README.md b/mindspace-service/README.md new file mode 100644 index 0000000..0e1b5fb --- /dev/null +++ b/mindspace-service/README.md @@ -0,0 +1,20 @@ +# MindSpace Service + +Standalone MindSpace service source tracked inside the Memind repo. + +This service is deployed to production as a separate runtime artifact under +`/Users/john/MindSpace` and exposes the shared adapter RPC contract that Memind +can call through `MINDSPACE_SERVER_ADAPTER=remote`. + +Production defaults: + +- MindSpace service listens on `127.0.0.1:8082` +- Memind remains on `127.0.0.1:8081` +- Runtime code lives at `/Users/john/MindSpace` +- Runtime data continues to use the existing persisted Memind directories: + `MindSpace/`, `data/mindspace/`, and `users/` + +Key scripts in this repo: + +- `node scripts/build-mindspace-service-runtime.mjs` +- `bash scripts/release-mindspace-service-prod.sh --yes` diff --git a/mindspace-service/mindspace-rpc-server.mjs b/mindspace-service/mindspace-rpc-server.mjs new file mode 100644 index 0000000..b3eb6ae --- /dev/null +++ b/mindspace-service/mindspace-rpc-server.mjs @@ -0,0 +1,160 @@ +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function loadContract(env = process.env) { + const memindRoot = path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source'); + return import( + pathToFileURL(path.join(memindRoot, 'mindspace-server-adapter-contract.mjs')).href + ); +} + +function parseBearerToken(headers) { + const auth = String(headers.authorization ?? ''); + if (!auth.startsWith('Bearer ')) return ''; + return auth.slice('Bearer '.length).trim(); +} + +async function readJsonBody(req) { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString('utf8').trim(); + return text ? JSON.parse(text) : {}; +} + +function reviveRpcValue(value) { + if ( + value && + typeof value === 'object' && + value.type === 'Buffer' && + Array.isArray(value.data) + ) { + return Buffer.from(value.data); + } + if (Array.isArray(value)) return value.map((item) => reviveRpcValue(item)); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, reviveRpcValue(item)]), + ); + } + return value; +} + +function json(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +export async function createMindSpaceRpcRequestHandler({ + adapter, + env = process.env, + logger = console, + serviceMeta = {}, +} = {}) { + const { + MINDSPACE_SERVER_ADAPTER_BINDINGS, + MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, + } = await loadContract(env); + const operationBasePath = String( + serviceMeta.operationBasePath ?? env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter', + ) + .trim() + .replace(/\/+$/, '') || '/mindspace/v1/adapter'; + const authToken = String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(); + + return async function handleMindSpaceRpc(req, res) { + try { + const url = new URL(req.url, 'http://127.0.0.1'); + if (req.method === 'GET' && url.pathname === '/health') { + return json(res, 200, { + ok: true, + service: 'mindspace-service', + adapterKind: adapter.kind, + implementationStatus: adapter.implementationStatus, + operationBasePath, + backgroundJobsManagedLocally: adapter.kind === 'local', + }); + } + if (req.method === 'GET' && url.pathname === '/mindspace/v1/contract') { + return json(res, 200, { + bindings: MINDSPACE_SERVER_ADAPTER_BINDINGS, + bindingKeys: MINDSPACE_SERVER_ADAPTER_BINDING_KEYS, + }); + } + if (req.method !== 'POST' || !url.pathname.startsWith(`${operationBasePath}/`)) { + return json(res, 404, { message: 'Not found' }); + } + if (authToken && parseBearerToken(req.headers) !== authToken) { + return json(res, 401, { message: 'Unauthorized' }); + } + const segments = url.pathname.slice(operationBasePath.length + 1).split('/').filter(Boolean); + if (segments.length !== 2) { + return json(res, 400, { message: 'Expected /:binding/:method' }); + } + const [bindingKey, method] = segments.map(decodeURIComponent); + const service = adapter?.[bindingKey]; + const allowedMethods = MINDSPACE_SERVER_ADAPTER_BINDINGS[bindingKey]; + if (!service || !allowedMethods) { + return json(res, 404, { message: `Unknown binding: ${bindingKey}` }); + } + if (!allowedMethods.includes(method)) { + return json(res, 404, { message: `Unknown method: ${bindingKey}.${method}` }); + } + const body = await readJsonBody(req); + const args = Array.isArray(body?.args) ? reviveRpcValue(body.args) : []; + const result = await service[method](...args); + return json(res, 200, result); + } catch (error) { + logger.error?.('[MindSpace RPC Error]', error); + return json(res, 500, { + message: error instanceof Error ? error.message : String(error), + }); + } + }; +} + +export async function startMindSpaceRpcServer({ + adapter, + env = process.env, + logger = console, + serviceMeta = {}, +} = {}) { + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env, + logger, + serviceMeta, + }); + const host = env.MINDSPACE_SERVICE_HOST ?? '127.0.0.1'; + const port = Number(env.MINDSPACE_SERVICE_PORT ?? 8082); + + const server = http.createServer((req, res) => { + void handler(req, res); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => resolve()); + }); + + return { + server, + host, + port, + close: () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }), + }; +} diff --git a/mindspace-service/mindspace-rpc-server.test.mjs b/mindspace-service/mindspace-rpc-server.test.mjs new file mode 100644 index 0000000..6b5bf95 --- /dev/null +++ b/mindspace-service/mindspace-rpc-server.test.mjs @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMindSpaceRpcRequestHandler } from './mindspace-rpc-server.mjs'; + +function createStubAdapter() { + const calls = { + writeUploadContent: [], + }; + return { + calls, + kind: 'local', + implementationStatus: 'ready', + serviceFacade: { + async prepareConversationPackageRead() { + return []; + }, + }, + conversationPackageRegistry: { + async ensurePackage() { return { ok: true }; }, + async putObjectForSession() { return { ok: true }; }, + async recordArtifact() { return { ok: true }; }, + async readArtifactObject() { return { ok: true }; }, + async readManifestForSession() { return { ok: true }; }, + async writeManifestForSession() { return { ok: true }; }, + }, + assetService: { + async cancelUpload() { return { ok: true }; }, + async claimUploadArtifactsForConversation() { return []; }, + async completeUpload() { return { ok: true }; }, + async createChatAsset() { return { ok: true }; }, + async createUpload() { return { ok: true }; }, + async deleteAsset() { return { ok: true }; }, + async listAssets() { return []; }, + async readAsset() { return { ok: true }; }, + async readPublicAsset() { return { ok: true }; }, + async renderAssetPreview() { return { preview: true }; }, + async renderAssetThumbnail() { return { thumbnail: true }; }, + async syncWorkspaceAssets() { return { synced: true }; }, + async writeUploadContent(...args) { + calls.writeUploadContent.push(args); + return { isBuffer: Buffer.isBuffer(args[2]), size: args[2]?.length ?? 0 }; + }, + async expireStaleUploads() { return { expired: 0 }; }, + }, + pageService: { + async createFromChat() { return { ok: true }; }, + async createPage() { return { ok: true }; }, + async deletePage() { return { ok: true }; }, + async findPageByRelativePath() { return null; }, + async findPageBySourceAsset() { return null; }, + async getDeletePreview() { return { ok: true }; }, + async getPage() { return { ok: true }; }, + async listPages() { return []; }, + async listVersions() { return []; }, + async localizePrivateResources() { return { ok: true }; }, + async redactPage() { return { ok: true }; }, + async regenerateThumbnail() { return { ok: true }; }, + async renderDraftPreview() { return { html: '' }; }, + async renderPreview() { return { html: '' }; }, + async renderThumbnail() { return { ok: true }; }, + async rewriteHtmlDownloadLinksForPage() { return { ok: true }; }, + async updatePage() { return { ok: true }; }, + async uploadThumbnail() { return { ok: true }; }, + }, + pageLiveEditService: { + async applyAgentPatch() { return { ok: true }; }, + async bindSession() { return { ok: true }; }, + async getRevisionSnapshot() { return { ok: true }; }, + }, + assetAgentService: { + async applyAgentDelete() { return { ok: true }; }, + }, + publicationService: { + async check() { return { ok: true }; }, + async cleanupExpiredUnconfirmedPublications() { return { cleaned: 0 }; }, + async getCurrent() { return null; }, + async getPublicHomepage() { return null; }, + async getStats() { return { ok: true }; }, + async offline() { return { ok: true }; }, + async publish() { return { ok: true }; }, + async resolvePrivateLink() { return { ok: true }; }, + async resolvePublic() { return { ok: true }; }, + async updatePublicationStatus() { return { ok: true }; }, + }, + cleanupService: { + async listCandidates() { return []; }, + async runCleanup() { return { ok: true }; }, + }, + agentJobService: { + async cancelJob() { return { ok: true }; }, + async claimJob() { return { ok: true }; }, + async claimNextJob() { return null; }, + async completeJob() { return { ok: true }; }, + async createJob() { return { ok: true }; }, + async getAssetForJob() { return { ok: true }; }, + async getJob() { return { ok: true }; }, + async heartbeat() { return { ok: true }; }, + async listJobs() { return []; }, + async reapStaleJobs() { return 0; }, + async retryJob() { return { ok: true }; }, + }, + }; +} + +async function runRequest(handler, { method = 'GET', path = '/', headers = {}, body = '' } = {}) { + const chunks = []; + const req = { + method, + url: path, + headers, + async *[Symbol.asyncIterator]() { + if (body) yield Buffer.from(body); + }, + }; + const res = { + statusCode: 0, + headers: {}, + writeHead(statusCode, responseHeaders) { + this.statusCode = statusCode; + this.headers = responseHeaders; + }, + end(chunk) { + if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))); + }, + }; + await handler(req, res); + return { + statusCode: res.statusCode, + headers: res.headers, + body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null, + }; +} + +test('health and contract endpoints are exposed without auth', async () => { + const handler = await createMindSpaceRpcRequestHandler({ + adapter: createStubAdapter(), + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + + const health = await runRequest(handler, { path: '/health' }); + const contract = await runRequest(handler, { path: '/mindspace/v1/contract' }); + + assert.equal(health.statusCode, 200); + assert.equal(health.body.ok, true); + assert.equal(contract.statusCode, 200); + assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview')); +}); + +test('rpc invocation enforces auth token and dispatches args to adapter method', async () => { + const handler = await createMindSpaceRpcRequestHandler({ + adapter: createStubAdapter(), + env: { + MINDSPACE_MEMIND_ROOT: '..', + MINDSPACE_REMOTE_AUTH_TOKEN: 'secret-token', + }, + }); + + const unauthorized = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/adapter/assetService/renderAssetPreview', + body: JSON.stringify({ args: [{ assetId: 'a1' }] }), + }); + const authorized = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/adapter/assetService/renderAssetPreview', + headers: { + authorization: 'Bearer secret-token', + }, + body: JSON.stringify({ args: [{ assetId: 'a1' }] }), + }); + + assert.equal(unauthorized.statusCode, 401); + assert.equal(authorized.statusCode, 200); + assert.deepEqual(authorized.body, { preview: true }); +}); + +test('rpc invocation revives JSON-serialized buffers before dispatch', async () => { + const adapter = createStubAdapter(); + const handler = await createMindSpaceRpcRequestHandler({ + adapter, + env: { + MINDSPACE_MEMIND_ROOT: '..', + }, + }); + const payload = Buffer.from('asset parity payload'); + + const response = await runRequest(handler, { + method: 'POST', + path: '/mindspace/v1/adapter/assetService/writeUploadContent', + body: JSON.stringify({ args: ['user-1', 'upload-1', payload] }), + }); + + assert.equal(response.statusCode, 200); + assert.deepEqual(response.body, { isBuffer: true, size: payload.length }); + assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true); +}); diff --git a/mindspace-service/mindspace-service-bootstrap.mjs b/mindspace-service/mindspace-service-bootstrap.mjs new file mode 100644 index 0000000..8f946e8 --- /dev/null +++ b/mindspace-service/mindspace-service-bootstrap.mjs @@ -0,0 +1,237 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function resolveMemindRoot(env = process.env) { + return path.resolve(__dirname, env.MINDSPACE_MEMIND_ROOT ?? '../memind-source'); +} + +function resolveStandaloneDataRoot(env = process.env) { + return path.resolve(env.MINDSPACE_SERVICE_H5_ROOT ?? path.join(__dirname, 'runtime')); +} + +function ensureStandaloneRuntimeLayout(standaloneDataRoot) { + fs.mkdirSync(standaloneDataRoot, { recursive: true }); + fs.mkdirSync(path.join(standaloneDataRoot, 'MindSpace'), { recursive: true }); + fs.mkdirSync(path.join(standaloneDataRoot, 'data'), { recursive: true }); + fs.mkdirSync(path.join(standaloneDataRoot, 'users'), { recursive: true }); +} + +function loadDotenvFile(targetEnv, filepath) { + if (!fs.existsSync(filepath) || !fs.statSync(filepath).isFile()) return; + const content = fs.readFileSync(filepath, 'utf8'); + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || !line.includes('=')) continue; + const [rawKey, ...rawValueParts] = line.split('='); + const key = rawKey.trim(); + if (!key || key in targetEnv) continue; + const rawValue = rawValueParts.join('=').trim(); + const unwrapped = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ? rawValue.slice(1, -1) + : rawValue; + targetEnv[key] = unwrapped; + } +} + +function applyMemindEnvDefaults(memindRoot, env = process.env) { + loadDotenvFile(env, path.join(memindRoot, '.env')); + loadDotenvFile(env, path.join(memindRoot, '.env.local')); + return env; +} + +async function importFromMemindRoot(memindRoot, relativePath) { + return import(pathToFileURL(path.join(memindRoot, relativePath)).href); +} + +async function loadMemindModules(memindRoot) { + const [ + db, + config, + conversationMemory, + localServerAdapter, + publicHtmlHelpers, + runtimeConfig, + sessionSnapshot, + userAuth, + agentRunner, + experienceService, + workspaceThumbnails, + workspaceSync, + ] = await Promise.all([ + importFromMemindRoot(memindRoot, 'db.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-config.mjs'), + importFromMemindRoot(memindRoot, 'conversation-memory.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-local-server-adapter.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-conversation-package-public-html.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-runtime-config.mjs'), + importFromMemindRoot(memindRoot, 'session-snapshot.mjs'), + importFromMemindRoot(memindRoot, 'user-auth.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-agent-runner.mjs'), + importFromMemindRoot(memindRoot, 'experience-service.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-workspace-thumbnails.mjs'), + importFromMemindRoot(memindRoot, 'mindspace-workspace-sync.mjs'), + ]); + return { + ...db, + ...config, + ...conversationMemory, + ...localServerAdapter, + ...publicHtmlHelpers, + ...runtimeConfig, + ...sessionSnapshot, + ...userAuth, + ...agentRunner, + ...experienceService, + ...workspaceThumbnails, + ...workspaceSync, + }; +} + +function parseApiTargets(env = process.env) { + const csvTargets = String(env.TKMIND_API_TARGETS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const legacyTargets = [env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006', env.TKMIND_API_TARGET_1].filter( + Boolean, + ); + return [...new Set([...csvTargets, ...legacyTargets])]; +} + +async function resolveUserIdForAgentSession(pool, sessionId) { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; +} + +async function resolveUserIdByDirKey(pool, dirKey) { + const key = String(dirKey ?? '').trim(); + if (!key) return null; + const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [key]); + return rows[0]?.id ?? key; +} + +export async function bootstrapMindSpaceService({ + env = process.env, + logger = console, +} = {}) { + const memindRoot = resolveMemindRoot(env); + applyMemindEnvDefaults(memindRoot, env); + const standaloneDataRoot = resolveStandaloneDataRoot(env); + ensureStandaloneRuntimeLayout(standaloneDataRoot); + const { + createDbPool, + initSchema, + isDatabaseConfigured, + ensureMindSpaceConfig, + createConversationMemoryService, + createMindSpaceLocalServerAdapter, + createMindSpaceAgentRunner, + createSessionSnapshotService, + createUserAuth, + createExperienceService, + registerPublicHtmlArtifactsForConversationPackage, + resolveMindSpaceServerRuntimeOptions, + startWorkspaceAssetSyncWatcher, + startWorkspaceThumbnailWatcher, + } = await loadMemindModules(memindRoot); + + if (!isDatabaseConfigured()) { + throw new Error('MindSpace service requires DATABASE_URL or MYSQL_* configuration'); + } + + const runtime = resolveMindSpaceServerRuntimeOptions(standaloneDataRoot, env); + const pool = createDbPool(); + await initSchema(pool); + await ensureMindSpaceConfig(pool, { env }); + const conversationMemoryService = createConversationMemoryService(pool); + const sessionSnapshotService = createSessionSnapshotService(pool, { + conversationMemoryService, + }); + const usersRoot = path.resolve(env.H5_USERS_ROOT ?? path.join(standaloneDataRoot, 'users')); + const userAuth = createUserAuth(pool, { + usersRoot, + h5Root: standaloneDataRoot, + env, + defaultSignupBalanceCents: Number(env.H5_SIGNUP_BALANCE_CENTS ?? 500), + }); + + const adapter = createMindSpaceLocalServerAdapter({ + pool, + h5Root: standaloneDataRoot, + env, + maxFileBytes: runtime.maxFileBytes, + publicPageLimit: runtime.publicPageLimit, + resolveUserIdForAgentSession: (sessionId) => resolveUserIdForAgentSession(pool, sessionId), + resolveSessionSnapshot: (sessionId) => sessionSnapshotService.get(sessionId), + registerPublicHtmlArtifactsForConversation: (payload) => + registerPublicHtmlArtifactsForConversationPackage({ + conversationPackageRegistry: adapter.conversationPackageRegistry, + h5Root: standaloneDataRoot, + env, + ...payload, + }), + logger, + }); + + const apiTargets = parseApiTargets(env); + const apiTarget = apiTargets[0] ?? 'https://127.0.0.1:18006'; + const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret'; + let experienceService = null; + if (runtime.experienceEnabled) { + experienceService = createExperienceService(pool); + } + const agentRunner = createMindSpaceAgentRunner({ + apiTarget, + apiSecret, + userAuth, + agentJobService: adapter.agentJobService, + experienceService, + }); + const standaloneAgentWorkerEnabled = + env.MINDSPACE_SERVICE_AGENT_WORKER_ENABLED === '1' || + env.MINDSPACE_SERVICE_AGENT_WORKER_ENABLED === 'true' || + (env.MINDSPACE_SERVICE_AGENT_WORKER_ENABLED == null && runtime.agentWorker.enabled); + + const backgroundJobs = adapter.startBackgroundJobs({ + publicationCleanupIntervalMs: 60 * 1000, + agentWorker: { + ...runtime.agentWorker, + enabled: standaloneAgentWorkerEnabled, + }, + agentRunner, + workspaceMaintenanceEnabled: env.MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE === 'true', + publishRoot: runtime.publishRoot, + startWorkspaceThumbnailWatcher, + startWorkspaceAssetSyncWatcher, + syncUserWorkspaceByDirKey: async (dirKey, options) => { + const userId = await resolveUserIdByDirKey(pool, dirKey); + if (!userId) return; + await adapter.assetService.syncWorkspaceAssets(userId, options); + }, + expireStaleUploadsIntervalMs: 5 * 60 * 1000, + }); + + return { + memindRoot, + standaloneDataRoot, + runtime, + pool, + conversationMemoryService, + sessionSnapshotService, + userAuth, + agentRunner, + adapter, + backgroundJobs, + async close() { + await pool.end(); + }, + }; +} diff --git a/mindspace-service/package.json b/mindspace-service/package.json new file mode 100644 index 0000000..69a2e3d --- /dev/null +++ b/mindspace-service/package.json @@ -0,0 +1,10 @@ +{ + "name": "mindspace-service", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.mjs", + "test": "node --test mindspace-rpc-server.test.mjs" + } +} diff --git a/mindspace-service/scripts/run-mindspace-prod.sh b/mindspace-service/scripts/run-mindspace-prod.sh new file mode 100644 index 0000000..a112071 --- /dev/null +++ b/mindspace-service/scripts/run-mindspace-prod.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +MEMIND_ROOT="${MEMIND_SHARED_ROOT:-/Users/john/Project/Memind}" + +if [[ -f "${MEMIND_ROOT}/.env" ]]; then + set -a + # shellcheck disable=SC1090 + source "${MEMIND_ROOT}/.env" + set +a +fi + +export NODE_ENV=production +export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}" +export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}" +export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${MEMIND_ROOT}/data/mindspace}" +export H5_USERS_ROOT="${H5_USERS_ROOT:-${MEMIND_ROOT}/users}" +export MINDSPACE_SERVICE_PORT="${MINDSPACE_SERVICE_PORT:-8082}" +export MINDSPACE_SERVICE_HOST="${MINDSPACE_SERVICE_HOST:-127.0.0.1}" +export MINDSPACE_REMOTE_AUTH_TOKEN="${MINDSPACE_REMOTE_AUTH_TOKEN:-${TKMIND_SERVER__SECRET_KEY:-}}" +export MINDSPACE_SERVICE_AGENT_WORKER_ENABLED="${MINDSPACE_SERVICE_AGENT_WORKER_ENABLED:-true}" +export MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE="${MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE:-true}" + +NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" +if [[ ! -x "${NODE_BIN}" ]]; then + NODE_BIN="$(command -v node)" +fi + +export NODE_PATH="${ROOT}/node_modules" + +exec "${NODE_BIN}" "${ROOT}/server.mjs" diff --git a/mindspace-service/server.mjs b/mindspace-service/server.mjs new file mode 100644 index 0000000..117f8e1 --- /dev/null +++ b/mindspace-service/server.mjs @@ -0,0 +1,42 @@ +import { bootstrapMindSpaceService } from './mindspace-service-bootstrap.mjs'; +import { startMindSpaceRpcServer } from './mindspace-rpc-server.mjs'; + +const logger = console; + +async function main() { + const service = await bootstrapMindSpaceService({ env: process.env, logger }); + const rpc = await startMindSpaceRpcServer({ + adapter: service.adapter, + env: process.env, + logger, + serviceMeta: { + operationBasePath: service.runtime.remote.operationBasePath, + }, + }); + + logger.log( + `MindSpace service listening on http://${rpc.host}:${rpc.port}${service.runtime.remote.operationBasePath}`, + ); + logger.log(`Memind root: ${service.memindRoot}`); + logger.log(`Standalone data root: ${service.standaloneDataRoot}`); + logger.log(`Standalone agent worker: ${service.backgroundJobs.agentWorker ? 'enabled' : 'disabled'}`); + + const shutdown = async (signal) => { + logger.log(`Received ${signal}, shutting down MindSpace service...`); + await rpc.close().catch(() => {}); + await service.close().catch(() => {}); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); +} + +main().catch((error) => { + console.error('MindSpace service failed to start:', error); + process.exit(1); +}); diff --git a/package.json b/package.json index c430aec..cb95646 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", "build:portal-runtime": "node scripts/build-portal-runtime.mjs", + "build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs", "check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only", "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", + "test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", diff --git a/scripts/build-mindspace-service-runtime.mjs b/scripts/build-mindspace-service-runtime.mjs new file mode 100644 index 0000000..bbe5db9 --- /dev/null +++ b/scripts/build-mindspace-service-runtime.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); +const runtimeRoot = path.join(root, '.runtime', 'mindspace-service'); +const serviceSourceRoot = path.join(root, 'mindspace-service'); +const nodeModulesDir = path.join(root, 'node_modules'); +const skipNodeModules = process.argv.includes('--skip-node-modules'); + +const excludeTopLevel = new Set([ + '.git', + '.claude', + '.cursor', + '.local', + '.runtime', + 'node_modules', + 'dist', + 'MindSpace', + 'data', + 'users', + 'logs', +]); + +async function exists(targetPath) { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +async function remove(targetPath) { + await fs.rm(targetPath, { recursive: true, force: true }); +} + +async function copyDir(source, target) { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.cp(source, target, { recursive: true, dereference: false }); +} + +function shouldCopyMemindSource(source) { + const relative = path.relative(root, source); + if (!relative) return true; + const parts = relative.split(path.sep); + const top = parts[0]; + const last = parts[parts.length - 1]; + if (excludeTopLevel.has(top)) return false; + if (last.endsWith('.pid')) return false; + if (last === '.DS_Store' || last === '.env' || last === '.env.local') return false; + if (top === 'public' && parts[1] === 'plaza-covers') return false; + return true; +} + +async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModulesDir) { + console.log('==> 重写 node_modules 顶层符号链接为 runtime 内相对路径'); + const localNodeModulesRoot = `${sourceNodeModulesDir}${path.sep}`; + + async function visit(dir) { + const entries = await fs.readdir(dir); + for (const entryName of entries) { + const fullPath = path.join(dir, entryName); + const stats = await fs.lstat(fullPath); + if (stats.isSymbolicLink()) { + const linkTarget = await fs.readlink(fullPath); + const normalizedTarget = path.normalize(linkTarget); + if (!normalizedTarget.startsWith(localNodeModulesRoot)) continue; + const relativeInsideNodeModules = normalizedTarget.slice(localNodeModulesRoot.length); + const runtimeTarget = path.join(runtimeNodeModulesDir, relativeInsideNodeModules); + const replacement = path.relative(path.dirname(fullPath), runtimeTarget); + await fs.unlink(fullPath); + await fs.symlink(replacement, fullPath); + continue; + } + if (stats.isDirectory()) { + await visit(fullPath); + } + } + } + + await visit(runtimeNodeModulesDir); +} + +async function copyServiceSource() { + console.log('==> 拷贝 MindSpace service 源文件'); + await remove(runtimeRoot); + await fs.mkdir(runtimeRoot, { recursive: true }); + await copyDir(serviceSourceRoot, runtimeRoot); + await remove(path.join(runtimeRoot, 'mindspace-rpc-server.test.mjs')); +} + +async function copyMemindSourceSnapshot() { + console.log('==> 拷贝 Memind 受控源码快照'); + const targetRoot = path.join(runtimeRoot, 'memind-source'); + await fs.mkdir(targetRoot, { recursive: true }); + const entries = await fs.readdir(root); + for (const entry of entries) { + if (excludeTopLevel.has(entry)) continue; + if (entry.endsWith('.pid')) continue; + if (entry === '.DS_Store' || entry === '.env' || entry === '.env.local') continue; + const sourcePath = path.join(root, entry); + if (!shouldCopyMemindSource(sourcePath)) continue; + await fs.cp(sourcePath, path.join(targetRoot, entry), { + recursive: true, + filter: shouldCopyMemindSource, + }); + } +} + +async function copyNodeModules() { + if (skipNodeModules) return; + if (!(await exists(nodeModulesDir))) { + throw new Error(`缺少 node_modules 目录: ${nodeModulesDir}`); + } + const resolvedNodeModulesDir = await fs.realpath(nodeModulesDir); + console.log('==> 拷贝生产运行依赖 node_modules'); + await copyDir(resolvedNodeModulesDir, path.join(runtimeRoot, 'node_modules')); + await rewriteNodeModulesSymlinks(path.join(runtimeRoot, 'node_modules'), resolvedNodeModulesDir); +} + +async function writeMetadata() { + const head = await fs.readFile(path.join(root, '.git', 'HEAD'), 'utf8').catch(() => 'unknown'); + const runbook = [ + 'MindSpace service runtime artifact', + '', + 'Production runtime target:', + ' /Users/john/MindSpace', + 'Shared persisted data root:', + ' /Users/john/Project/Memind', + '', + 'This artifact contains:', + ' server.mjs', + ' mindspace-rpc-server.mjs', + ' mindspace-service-bootstrap.mjs', + ' scripts/run-mindspace-prod.sh', + ' memind-source/ (controlled source snapshot, no runtime data)', + ' node_modules/ (runtime dependencies)', + '', + 'This artifact must not contain:', + ' MindSpace/ runtime data', + ' data/mindspace/ runtime data', + ' users/ runtime data', + ' .env / .env.local', + '', + 'The production service keeps using shared persisted paths from /Users/john/Project/Memind.', + '', + `Git head ref: ${head.trim()}`, + ].join('\n'); + await fs.writeFile(path.join(runtimeRoot, 'RUNBOOK.txt'), runbook, 'utf8'); +} + +async function main() { + if (!(await exists(serviceSourceRoot))) { + throw new Error(`缺少 MindSpace service 源目录: ${serviceSourceRoot}`); + } + await copyServiceSource(); + await copyMemindSourceSnapshot(); + await copyNodeModules(); + await writeMetadata(); + await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-mindspace-prod.sh'), 0o755); + console.log(''); + console.log(`MindSpace service runtime 已生成: ${runtimeRoot}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/release-mindspace-service-prod.sh b/scripts/release-mindspace-service-prod.sh new file mode 100644 index 0000000..c25f0db --- /dev/null +++ b/scripts/release-mindspace-service-prod.sh @@ -0,0 +1,384 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${STUDIO_HOST:-58.38.22.103}" +REMOTE_USER_ROOT="${STUDIO_USER_ROOT:-/Users/john}" +SHARED_MEMIND_ROOT="${STUDIO_SHARED_MEMIND_ROOT:-/Users/john/Project/Memind}" +APP_DIR="${REMOTE_USER_ROOT}/MindSpace" +INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime" +BACKUP_DIR="/Users/john/Project/backups/mindspace-service" +ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service" +HEALTH_URL="${MINDSPACE_SERVICE_HEALTH_URL:-http://127.0.0.1:8082/health}" +CONTRACT_URL="${MINDSPACE_SERVICE_CONTRACT_URL:-http://127.0.0.1:8082/mindspace/v1/contract}" +PORTAL_HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}" +SERVICE_LABEL="cn.tkmind.mindspace-service" +PORTAL_LABEL="cn.tkmind.memind-portal" +LAUNCHD_GUI="gui/$(id -u)" +RELEASE_TS="$(date +%Y%m%d-%H%M%S)" +SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)" +RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mindspace-service-release.XXXXXX")" +RUNTIME_ROOT="${ROOT}/.runtime/mindspace-service" +BUNDLE_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.tar.gz" +MANIFEST_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt" +SHA_PATH="${TMP_DIR}/mindspace-service-${RELEASE_ID}.sha256" +DRY_RUN=0 +SKIP_TESTS=0 +SKIP_BUILD=0 +AUTO_YES=0 + +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +用法: + bash scripts/release-mindspace-service-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes] + +说明: + 1. 本地构建 MindSpace service runtime artifact + 2. 上传到 103 + 3. 103 备份当前 /Users/john/MindSpace(如存在) + 4. 103 备份 /Users/john/Project/Memind/.env + 5. 启动 /Users/john/MindSpace 独立服务(127.0.0.1:8082) + 6. 将 Memind 切到 remote adapter 并重启 Portal + 7. 失败时恢复旧 MindSpace 服务目录与 Memind .env +EOF +} + +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 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac + shift +done + +say() { + printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "缺少命令: $1" >&2 + exit 1 + } +} + +need_cmd ssh +need_cmd scp +need_cmd tar +need_cmd shasum + +if [[ -x "${ROOT}/scripts/check-release-ready.sh" ]]; then + bash "${ROOT}/scripts/check-release-ready.sh" +else + echo "缺少发布前闸门: ${ROOT}/scripts/check-release-ready.sh" >&2 + exit 1 +fi + +say "本地预检查" +ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo mindspace-service-ssh-ok" >/dev/null +ssh -o BatchMode=yes "${HOST}" "test -d '${SHARED_MEMIND_ROOT}' && test -f '${SHARED_MEMIND_ROOT}/.env'" >/dev/null + +if [[ "${SKIP_TESTS}" -ne 1 ]]; then + say "运行最小验证" + ( + cd "${ROOT}" + node --test mindspace-service/mindspace-rpc-server.test.mjs >/dev/null + ) +fi + +if [[ "${SKIP_BUILD}" -ne 1 ]]; then + say "构建 MindSpace service runtime" + ( + cd "${ROOT}" + node scripts/build-mindspace-service-runtime.mjs + ) +fi + +[[ -d "${RUNTIME_ROOT}" ]] || { + echo "缺少 runtime 目录: ${RUNTIME_ROOT}" >&2 + exit 1 +} + +verify_runtime_artifact() { + local missing=0 + for required in README.md package.json server.mjs mindspace-rpc-server.mjs mindspace-service-bootstrap.mjs scripts/run-mindspace-prod.sh memind-source RUNBOOK.txt; do + if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then + echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2 + missing=1 + fi + done + if [[ "${missing}" -ne 0 ]]; then + exit 1 + fi +} + +verify_runtime_artifact + +say "生成发布清单" +cat > "${MANIFEST_PATH}" < "${SHA_PATH}" + +if [[ "${DRY_RUN}" -eq 1 ]]; then + say "dry-run 完成" + ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" + exit 0 +fi + +if [[ "${AUTO_YES}" -ne 1 ]]; then + echo "" + echo "即将部署 MindSpace service 到 ${HOST}:${APP_DIR}" + echo "并把 Memind 切换到 remote adapter -> http://127.0.0.1:8082" + read -r -p "确认继续?[y/N] " reply + if [[ ! "${reply}" =~ ^[Yy]$ ]]; then + echo "已取消" + exit 1 + fi +fi + +say "上传 MindSpace service runtime 到 103" +ssh "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'" +scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${HOST}:${INCOMING_DIR}/" + +say "在 103 切换 MindSpace service 并更新 Memind remote adapter" +ssh -o BatchMode=yes "${HOST}" 'bash -s' -- "${RELEASE_ID}" <<'REMOTE' +set -euo pipefail + +RELEASE_ID="$1" +USER_ROOT="/Users/john" +APP_DIR="${USER_ROOT}/MindSpace" +NEW_DIR="${USER_ROOT}/MindSpace.release-${RELEASE_ID}" +INCOMING_DIR="/Users/john/Project/incoming/mindspace-service-runtime" +BACKUP_DIR="/Users/john/Project/backups/mindspace-service" +ARCHIVE_DIR="/Users/john/Project/archives/mindspace-service" +SHARED_MEMIND_ROOT="/Users/john/Project/Memind" +BUNDLE="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.tar.gz" +MANIFEST="${INCOMING_DIR}/mindspace-service-${RELEASE_ID}.manifest.txt" +ENV_FILE="${SHARED_MEMIND_ROOT}/.env" +ENV_BACKUP="${BACKUP_DIR}/memind-env-${RELEASE_ID}.before" +APP_BACKUP="${BACKUP_DIR}/mindspace-service-${RELEASE_ID}.before.tar.gz" +OLD_APP_ARCHIVE="${ARCHIVE_DIR}/MindSpace-before-${RELEASE_ID}" +SERVICE_LABEL="cn.tkmind.mindspace-service" +PORTAL_LABEL="cn.tkmind.memind-portal" +LAUNCHD_GUI="gui/$(id -u)" +SERVICE_PLIST="${HOME}/Library/LaunchAgents/${SERVICE_LABEL}.plist" +SERVICE_PLIST_BAK="${BACKUP_DIR}/${SERVICE_LABEL}.${RELEASE_ID}.plist.bak" +SERVICE_STARTED=0 +PORTAL_TOUCHED=0 +APP_MOVED=0 +HAD_OLD_APP=0 + +say() { + printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*" +} + +rollback() { + set +e + say "rollback: restoring previous state" + if [[ "${PORTAL_TOUCHED}" == "1" && -f "${ENV_BACKUP}" ]]; then + cp "${ENV_BACKUP}" "${ENV_FILE}" + launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true + fi + if [[ "${SERVICE_STARTED}" == "1" ]]; then + launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true + fi + if [[ -f "${SERVICE_PLIST_BAK}" ]]; then + cp "${SERVICE_PLIST_BAK}" "${SERVICE_PLIST}" + else + rm -f "${SERVICE_PLIST}" + fi + if [[ "${APP_MOVED}" == "1" ]]; then + rm -rf "${APP_DIR}" + if [[ "${HAD_OLD_APP}" == "1" && -d "${OLD_APP_ARCHIVE}" ]]; then + mv "${OLD_APP_ARCHIVE}" "${APP_DIR}" + fi + fi + if [[ "${HAD_OLD_APP}" == "1" && -f "${SERVICE_PLIST}" ]]; then + launchctl bootstrap "${LAUNCHD_GUI}" "${SERVICE_PLIST}" >/dev/null 2>&1 || true + launchctl kickstart -k "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true + fi + exit 1 +} + +trap rollback ERR + +[[ -f "${BUNDLE}" && -f "${MANIFEST}" ]] + +say "校验产物完整性" +cd "${INCOMING_DIR}" +tar -tzf "${BUNDLE}" >/dev/null + +say "备份 Memind .env" +cp "${ENV_FILE}" "${ENV_BACKUP}" + +if [[ -d "${APP_DIR}" ]]; then + HAD_OLD_APP=1 + say "备份旧 MindSpace service 目录" + COPYFILE_DISABLE=1 tar -czf "${APP_BACKUP}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" +fi + +say "准备新的 runtime 目录" +rm -rf "${NEW_DIR}" +mkdir -p "${NEW_DIR}" +tar -xzf "${BUNDLE}" -C "${NEW_DIR}" +cp "${MANIFEST}" "${NEW_DIR}/.release-manifest.txt" + +say "更新 MindSpace service LaunchAgent" +if [[ -f "${SERVICE_PLIST}" ]]; then + cp "${SERVICE_PLIST}" "${SERVICE_PLIST_BAK}" +fi +cat > "${SERVICE_PLIST}" < + + + + Label + ${SERVICE_LABEL} + ProgramArguments + + ${APP_DIR}/scripts/run-mindspace-prod.sh + + WorkingDirectory + ${APP_DIR} + EnvironmentVariables + + PATH + /opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin + MEMIND_SHARED_ROOT + ${SHARED_MEMIND_ROOT} + MINDSPACE_SERVICE_PORT + 8082 + MINDSPACE_SERVICE_HOST + 127.0.0.1 + MINDSPACE_SERVICE_AGENT_WORKER_ENABLED + true + MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE + true + + RunAtLoad + + KeepAlive + + StandardOutPath + ${HOME}/Library/Logs/mindspace-service.log + StandardErrorPath + ${HOME}/Library/Logs/mindspace-service.log + + +EOF + +say "切换 MindSpace service live 目录" +launchctl bootout "${LAUNCHD_GUI}/${SERVICE_LABEL}" >/dev/null 2>&1 || true +rm -rf "${OLD_APP_ARCHIVE}" +if [[ -d "${APP_DIR}" ]]; then + mv "${APP_DIR}" "${OLD_APP_ARCHIVE}" +fi +mv "${NEW_DIR}" "${APP_DIR}" +APP_MOVED=1 + +say "启动 MindSpace service" +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 +SERVICE_STARTED=1 + +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 + break + fi + sleep 1 +done +[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/health || true)" == "200" ]] +[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8082/mindspace/v1/contract || true)" == "200" ]] + +set_env() { + local key="$1" + local value="$2" + local file="$3" + local tmp + tmp="$(mktemp "${TMPDIR:-/tmp}/env.XXXXXX")" + awk -v k="${key}" -v v="${value}" ' + BEGIN { done = 0 } + index($0, k "=") == 1 { + if (!done) { + print k "=" v + done = 1 + } + next + } + { print } + END { + if (!done) print k "=" v + } + ' "${file}" > "${tmp}" + mv "${tmp}" "${file}" +} + +TOKEN="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' "${ENV_FILE}" | tail -1 | cut -d= -f2- || true)" +if [[ -z "${TOKEN}" ]]; then + echo "Memind .env 缺少 TKMIND_SERVER__SECRET_KEY,无法写入 remote adapter token" >&2 + exit 1 +fi + +say "切换 Memind 到 remote adapter" +set_env MINDSPACE_SERVER_ADAPTER remote "${ENV_FILE}" +set_env MINDSPACE_REMOTE_BASE_URL http://127.0.0.1:8082 "${ENV_FILE}" +set_env MINDSPACE_REMOTE_AUTH_TOKEN "${TOKEN}" "${ENV_FILE}" +set_env MEMIND_WORKSPACE_MAINTENANCE 0 "${ENV_FILE}" +PORTAL_TOUCHED=1 +launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true + +for _ in $(seq 1 30); do + code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" + if [[ "${code}" == "200" ]]; then + break + fi + sleep 1 +done +[[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8081/api/status || true)" == "200" ]] + +trap - ERR + +say "切换完成" +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)" +REMOTE + +say "发布完成" +printf 'release_id=%s\n' "${RELEASE_ID}" +printf 'bundle=%s\n' "${BUNDLE_PATH}" +printf 'manifest=%s\n' "${MANIFEST_PATH}" +printf 'sha=%s\n' "${SHA_PATH}"