Add MindSpace service runtime release chain
This commit is contained in:
@@ -2,6 +2,7 @@ node_modules/
|
||||
ops/node_modules/
|
||||
dist/
|
||||
ops/dist/
|
||||
.runtime/
|
||||
|
||||
.env
|
||||
.env.local
|
||||
|
||||
@@ -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 目录继承。
|
||||
|
||||
@@ -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` 来做开发预览;它会释放生产端口。
|
||||
|
||||
@@ -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`
|
||||
@@ -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();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
});
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}" <<EOF
|
||||
release_id=${RELEASE_ID}
|
||||
created_at=$(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
host=$(hostname)
|
||||
git_head=$(git -C "${ROOT}" rev-parse HEAD)
|
||||
git_branch=$(git -C "${ROOT}" branch --show-current)
|
||||
artifact=.runtime/mindspace-service
|
||||
service_root=${APP_DIR}
|
||||
shared_memind_root=${SHARED_MEMIND_ROOT}
|
||||
service_port=8082
|
||||
EOF
|
||||
|
||||
say "打包 MindSpace service runtime artifact"
|
||||
COPYFILE_DISABLE=1 tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" .
|
||||
shasum -a 256 "${BUNDLE_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}" <<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>${SERVICE_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${APP_DIR}/scripts/run-mindspace-prod.sh</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${APP_DIR}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
|
||||
<key>MEMIND_SHARED_ROOT</key>
|
||||
<string>${SHARED_MEMIND_ROOT}</string>
|
||||
<key>MINDSPACE_SERVICE_PORT</key>
|
||||
<string>8082</string>
|
||||
<key>MINDSPACE_SERVICE_HOST</key>
|
||||
<string>127.0.0.1</string>
|
||||
<key>MINDSPACE_SERVICE_AGENT_WORKER_ENABLED</key>
|
||||
<string>true</string>
|
||||
<key>MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE</key>
|
||||
<string>true</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${HOME}/Library/Logs/mindspace-service.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${HOME}/Library/Logs/mindspace-service.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
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}"
|
||||
Reference in New Issue
Block a user