Add MindSpace decoupling foundation
This commit is contained in:
@@ -294,6 +294,58 @@ export async function migrateSchema(pool) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
// MindSpace conversation packages: additive provenance tables for grouping
|
||||
// images, files, pages, and publications created during a chat session.
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) NULL,
|
||||
manifest_asset_id CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_conversation_package_session (user_id, session_id),
|
||||
KEY idx_h5_conversation_package_user_updated (user_id, updated_at),
|
||||
CONSTRAINT fk_h5_conversation_package_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_package_manifest FOREIGN KEY (manifest_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id CHAR(36) NULL,
|
||||
page_id CHAR(36) NULL,
|
||||
publication_id CHAR(36) NULL,
|
||||
agent_run_id CHAR(36) NULL,
|
||||
message_id VARCHAR(128) NULL,
|
||||
role VARCHAR(32) NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
mime_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT NULL,
|
||||
storage_key VARCHAR(512) NULL,
|
||||
canonical_url VARCHAR(512) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_conversation_artifact_package (package_id, sort_order, created_at),
|
||||
KEY idx_h5_conversation_artifact_asset (asset_id),
|
||||
KEY idx_h5_conversation_artifact_page (page_id),
|
||||
KEY idx_h5_conversation_artifact_publication (publication_id),
|
||||
KEY idx_h5_conversation_artifact_run (agent_run_id),
|
||||
KEY idx_h5_conversation_artifact_message (message_id),
|
||||
CONSTRAINT fk_h5_conversation_artifact_package FOREIGN KEY (package_id) REFERENCES h5_conversation_packages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_artifact_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_run FOREIGN KEY (agent_run_id) REFERENCES h5_agent_runs(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS h5_user_memory_items (
|
||||
id CHAR(64) PRIMARY KEY,
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
# Memind / MindSpace / Goose 解耦推进方案
|
||||
|
||||
日期: 2026-07-02
|
||||
|
||||
适用目标:
|
||||
|
||||
- 把 MindSpace 从 Memind Portal 单体中拆出来,成为可以独立部署、独立扩容、独立迁移存储的服务。
|
||||
- Memind 继续负责产品入口、用户会话、聊天体验、套餐/能力编排和业务路由。
|
||||
- Goose 继续负责 agent 执行,但不直接拥有 MindSpace 状态,也不把某台机器的文件路径当作长期契约。
|
||||
- 每次对话生成或上传的图片、文件、页面、公开链接等,都要形成一个可浏览、可迁移、可打包的 conversation package。
|
||||
|
||||
本文只描述架构目标、边界和推进计划,不要求一次性完成所有代码拆分。
|
||||
|
||||
## 0. 结论
|
||||
|
||||
应该拆,但不要按“105 = Control Portal,103 = Execution Portal”的机器绑定方式拆。
|
||||
|
||||
正确目标是服务边界:
|
||||
|
||||
```text
|
||||
Memind App
|
||||
- H5 / 服务号 / 认证入口 / 用户会话
|
||||
- 聊天 UI 和产品业务编排
|
||||
- 套餐、能力、计费、请求整形
|
||||
- 不拼接 MindSpace 物理路径
|
||||
- 不假设 MindSpace 部署在哪台机器
|
||||
|
||||
MindSpace Service
|
||||
- space / asset / page / publication / conversation package
|
||||
- canonical URL 生成和 public backing file 校验
|
||||
- 存储适配器: local fs / NAS / S3 / 其它对象存储
|
||||
- 权限、审计、manifest、元数据和实际文件一致性
|
||||
- 可以独立部署在任意位置
|
||||
|
||||
Goose Runtime
|
||||
- agent session / tool execution / long running job
|
||||
- 通过 MindSpace API 或 MindSpace MCP 读写文件
|
||||
- 不直接拥有 MindSpace 元数据
|
||||
- 不把本地 `MindSpace/<userId>` 路径作为产品级契约
|
||||
```
|
||||
|
||||
103/105 只能作为当前生产部署拓扑的一个例子。103 是生产,不是本地开发依赖,也不应该成为 MindSpace 独立化后的架构前提。
|
||||
|
||||
硬性规则:
|
||||
|
||||
1. MindSpace 的真实存储位置必须可替换,本地开发、NAS、S3 都应该只是 storage adapter。
|
||||
2. Memind 不得根据 userId、slug、fileName 自己拼接 MindSpace public URL。
|
||||
3. Goose 不得直接写业务数据库里的 MindSpace 状态,只能通过 MindSpace API/MCP 能力写入。
|
||||
4. MindSpace Service 是 asset/page/publication/package 元数据和 canonical URL 的唯一权威。
|
||||
5. 每个用户对话产生的文件必须归属到一个 conversation package,不能只散落在物理目录里。
|
||||
6. 物理目录结构不是外部契约;外部契约应该是 package id、asset id、page id、publication id 和 canonical URL。
|
||||
|
||||
## 1. 当前现状
|
||||
|
||||
当前 `/Users/john/Project/Memind` 更接近一个运行态 Portal 单体:
|
||||
|
||||
- `server.mjs` 同时承载 H5 API、用户认证、MindSpace API、文件/发布服务、agent session proxy、Agent job worker、workspace watcher、微信/Plaza 等。
|
||||
- Goose 是执行后端,Portal 通过 `TKMIND_API_TARGETS` / `TKMIND_API_TARGET` 调它。
|
||||
- MindSpace 有两类本地状态:
|
||||
- workspace/public 文件: `MindSpace/<userId>/...`
|
||||
- asset/page/publication 存储: `data/mindspace/users/...`
|
||||
- MySQL/RDS 保存用户、资产、页面、发布、任务、会话、计费、Plaza/微信等业务状态。
|
||||
- `h5_page_records` 已有 `source_session_id`、`source_message_id`,`h5_agent_jobs` 也有 `session_id`,说明页面和任务已经部分具备对话来源线索。
|
||||
- 但 `h5_assets` 这类底层文件资产还没有完整的 conversation package 归属模型。
|
||||
|
||||
现在最危险的隐含假设不是 HTTP 能不能转发,而是代码里把“某台机器上的路径”和“产品里的 MindSpace 文件”混成了同一个概念。未来 MindSpace 可以在本机、NAS、S3 或独立服务中,Memind 和 Goose 都不能依赖这层物理细节。
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U["Browser / WeChat"] --> M["Memind App"]
|
||||
M --> MS["MindSpace Service"]
|
||||
M --> AG["Agent Gateway"]
|
||||
AG --> G["Goose Runtime"]
|
||||
G --> MCP["MindSpace MCP / API"]
|
||||
MCP --> MS
|
||||
MS --> DB["Metadata DB"]
|
||||
MS --> ST["Storage Adapter"]
|
||||
ST --> LFS["Local FS"]
|
||||
ST --> NAS["NAS"]
|
||||
ST --> S3["S3 / Object Storage"]
|
||||
```
|
||||
|
||||
核心变化:
|
||||
|
||||
- Memind App 面向用户,不面向物理文件系统。
|
||||
- MindSpace Service 面向文件、页面、发布、包和存储后端。
|
||||
- Goose Runtime 面向执行,不面向 MindSpace 业务所有权。
|
||||
- Storage Adapter 面向字节和对象,不面向用户、会话、页面这些业务概念。
|
||||
|
||||
## 3. 服务边界
|
||||
|
||||
### 3.1 Memind App
|
||||
|
||||
Memind App 负责:
|
||||
|
||||
- H5 静态入口和聊天体验。
|
||||
- 服务号 webhook、菜单、客服入口。
|
||||
- 用户登录、session、cookie/header 规范化。
|
||||
- 套餐、能力、计费和业务策略。
|
||||
- 把用户请求转成 MindSpace API、Agent Gateway API 调用。
|
||||
- 展示 MindSpace 返回的 URL、JSON、SSE、HTML 状态。
|
||||
|
||||
Memind App 禁止:
|
||||
|
||||
- 拼接 `MindSpace/<userId>/public/*.html`。
|
||||
- 直接读写 `MindSpace/` 或 `data/mindspace/`。
|
||||
- 根据文件名猜测 public URL。
|
||||
- 自己判断 public backing file 是否存在。
|
||||
- 把具体机器路径传给前端当长期 API。
|
||||
|
||||
### 3.2 MindSpace Service
|
||||
|
||||
MindSpace Service 负责:
|
||||
|
||||
- Space、category、asset、asset version、page、page version、publication。
|
||||
- Conversation package 和 package manifest。
|
||||
- Canonical public URL 生成。
|
||||
- Public backing file 存在性校验。
|
||||
- 文件上传、下载、预览、缩略图、长图、HTML 页面落盘。
|
||||
- 存储适配器封装: local fs、NAS、S3、其它对象存储。
|
||||
- 权限校验、审计、配额和一致性修复。
|
||||
|
||||
MindSpace Service 应该提供稳定 API:
|
||||
|
||||
```text
|
||||
/api/mindspace/v1/spaces
|
||||
/api/mindspace/v1/assets
|
||||
/api/mindspace/v1/pages
|
||||
/api/mindspace/v1/publications
|
||||
/api/mindspace/v1/conversation-packages
|
||||
/api/mindspace/v1/conversation-packages/:packageId/manifest
|
||||
/api/mindspace/v1/conversation-packages/:packageId/artifacts
|
||||
```
|
||||
|
||||
### 3.3 Goose Runtime
|
||||
|
||||
Goose Runtime 负责:
|
||||
|
||||
- Agent session start/reply/resume/events。
|
||||
- Tool execution。
|
||||
- Long running job。
|
||||
- 通过 MindSpace MCP/API 读取输入文件、写入生成文件、创建页面、发布页面。
|
||||
|
||||
Goose Runtime 不应直接负责:
|
||||
|
||||
- MindSpace 数据库表的业务写入。
|
||||
- Public URL 拼接。
|
||||
- 页面发布状态判断。
|
||||
- Conversation package manifest 的最终一致性。
|
||||
|
||||
短期内,如果 local filesystem adapter 仍需要 `sandboxRoot`,可以继续保留本地路径注入。但它只能是某个 adapter 的内部实现,不应该是 Memind/MindSpace/Goose 之间的长期契约。
|
||||
|
||||
## 4. Conversation Package 模型
|
||||
|
||||
用户要求的“每次对话里面的文件形成一个文件夹包”,建议抽象成 conversation package。
|
||||
|
||||
它在产品上像文件夹,在系统里是元数据包:
|
||||
|
||||
```text
|
||||
mindspace://users/<userId>/conversations/<sessionId>/
|
||||
manifest.json
|
||||
input/
|
||||
images/
|
||||
files/
|
||||
pages/
|
||||
public/
|
||||
generated/
|
||||
```
|
||||
|
||||
注意: 上面是逻辑视图,不要求物理存储必须长这样。local fs 可以映射成真实目录,NAS 可以映射成共享目录,S3 可以映射成 object prefix。
|
||||
|
||||
Package manifest 应包含:
|
||||
|
||||
- `packageId`
|
||||
- `userId`
|
||||
- `sessionId`
|
||||
- `title`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
- `storagePrefix`
|
||||
- `artifacts[]`
|
||||
- `pages[]`
|
||||
- `publications[]`
|
||||
- `messages[]`
|
||||
- `agentRuns[]`
|
||||
|
||||
Artifact 应至少包含:
|
||||
|
||||
- `artifactId`
|
||||
- `assetId`
|
||||
- `pageId`
|
||||
- `publicationId`
|
||||
- `messageId`
|
||||
- `agentRunId`
|
||||
- `kind`: `input_image` / `input_file` / `generated_image` / `generated_file` / `page` / `public_html` / `long_image`
|
||||
- `displayName`
|
||||
- `mimeType`
|
||||
- `sizeBytes`
|
||||
- `storageKey`
|
||||
- `canonicalUrl`
|
||||
- `createdAt`
|
||||
|
||||
推荐新增表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id VARCHAR(64) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) DEFAULT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) DEFAULT NULL,
|
||||
manifest_asset_id VARCHAR(64) DEFAULT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_conversation_package_session (user_id, session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id VARCHAR(64) DEFAULT NULL,
|
||||
page_id VARCHAR(64) DEFAULT NULL,
|
||||
publication_id VARCHAR(64) DEFAULT NULL,
|
||||
agent_run_id VARCHAR(128) DEFAULT NULL,
|
||||
message_id VARCHAR(128) DEFAULT NULL,
|
||||
role VARCHAR(32) DEFAULT NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_conversation_artifacts_package (package_id),
|
||||
KEY idx_conversation_artifacts_asset (asset_id),
|
||||
KEY idx_conversation_artifacts_page (page_id),
|
||||
KEY idx_conversation_artifacts_message (message_id)
|
||||
);
|
||||
```
|
||||
|
||||
也可以先用轻量方案过渡:
|
||||
|
||||
- 给 `h5_assets` 增加 `source_session_id`、`source_message_id`、`source_run_id`。
|
||||
- 利用现有 `h5_page_records.source_session_id`、`h5_page_records.source_message_id` 回填页面来源。
|
||||
- 利用现有 `h5_agent_jobs.session_id` 回填 agent 生成物来源。
|
||||
- 先做 package view,再补完整 package tables。
|
||||
|
||||
## 5. Storage Adapter
|
||||
|
||||
MindSpace Service 内部需要统一存储接口:
|
||||
|
||||
```ts
|
||||
interface MindSpaceStorageAdapter {
|
||||
putObject(key, body, options)
|
||||
getObject(key)
|
||||
statObject(key)
|
||||
deleteObject(key)
|
||||
listObjects(prefix, options)
|
||||
copyObject(sourceKey, targetKey, options)
|
||||
createReadStream(key)
|
||||
createWriteStream(key, options)
|
||||
getSignedUrl(key, options)
|
||||
}
|
||||
```
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
MindSpace Domain
|
||||
-> Asset/Page/Publication/Package services
|
||||
-> Storage adapter interface
|
||||
-> local fs / NAS / S3 implementations
|
||||
```
|
||||
|
||||
关键要求:
|
||||
|
||||
- Memind 不知道 `storageKey` 如何映射到磁盘或 S3。
|
||||
- Goose 不直接生成 public URL,只提交产物给 MindSpace。
|
||||
- public URL 可以是 MindSpace Service 的 route,也可以是 CDN/object storage URL,但必须由 MindSpace Service 生成。
|
||||
- 本地开发默认用 local fs adapter,不依赖 103。
|
||||
- 生产可以继续先用现有机器路径,之后切 NAS/S3 时不改 Memind/Goose 业务层。
|
||||
|
||||
## 6. Agent 执行契约调整
|
||||
|
||||
当前 agent policy 里的关键值包括:
|
||||
|
||||
- `sandboxRoot`
|
||||
- `serverPath`
|
||||
- `nodeExecPath`
|
||||
- MCP envs
|
||||
|
||||
这些仍可作为短期兼容层,但长期应改成:
|
||||
|
||||
```json
|
||||
{
|
||||
"mindspace": {
|
||||
"workspaceRef": "mindspace://users/u_xxx/conversations/s_xxx",
|
||||
"packageId": "cp_xxx",
|
||||
"apiBaseUrl": "https://mindspace.example.com",
|
||||
"accessToken": "scoped-short-lived-token",
|
||||
"allowedTools": ["read_file", "write_file", "edit_file", "publish_page"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
推进原则:
|
||||
|
||||
- Local fs adapter 可以把 `workspaceRef` 映射到本机临时目录或真实目录。
|
||||
- NAS adapter 可以把 `workspaceRef` 映射到共享挂载目录。
|
||||
- S3 adapter 不应暴露 raw fs path,应通过 MCP/API 完成读写和预览。
|
||||
- Agent 写入完成后,MindSpace Service 负责把产物登记到 package manifest。
|
||||
|
||||
## 7. 推进阶段
|
||||
|
||||
### P0: 盘点和冻结边界
|
||||
|
||||
目标: 先明确哪些代码还在直接使用路径、URL、数据库表。
|
||||
|
||||
输出:
|
||||
|
||||
- 路径调用点清单: `MindSpace/`、`data/mindspace/`、`sandboxRoot`、`public/*.html`。
|
||||
- URL 调用点清单: `/MindSpace/`、`canonicalUrl`、`publicationUrl`。
|
||||
- DB 调用点清单: asset/page/publication/job/session 相关表。
|
||||
- 当前本地开发拓扑和生产拓扑分开写清楚,避免继续把 103 当开发依赖。
|
||||
|
||||
### P1: 在单体内抽出 MindSpace Service 边界
|
||||
|
||||
目标: 不改部署方式,先把边界从代码里立起来。
|
||||
|
||||
动作:
|
||||
|
||||
- 新增或整理 `MindSpaceService` domain 层。
|
||||
- 引入 `MindSpaceStorageAdapter` interface。
|
||||
- local fs adapter 先包住现有 `MindSpace/` 和 `data/mindspace/`。
|
||||
- 禁止新代码直接拼接 MindSpace public URL。
|
||||
- 给 public URL 生成和 backing file 校验加集中入口。
|
||||
|
||||
验收:
|
||||
|
||||
- 现有 H5、公开页、页面发布、文件下载行为不变。
|
||||
- 代码搜索可以证明新增业务不再直接依赖物理路径。
|
||||
|
||||
### P2: Conversation Package 首先落地
|
||||
|
||||
目标: 用户每次对话生成的图片、文件、页面、公开链接都能在一个包里看到。
|
||||
|
||||
动作:
|
||||
|
||||
- 为每个 chat session 创建或懒创建 conversation package。
|
||||
- 上传文件、图片描述、页面生成、长图、HTML 发布都写入 artifact 归属。
|
||||
- 新增 package manifest API。
|
||||
- H5 增加 package view: 图片、文件、页面、公开链接按对话聚合展示。
|
||||
- 对历史数据做 best-effort 回填。
|
||||
|
||||
验收:
|
||||
|
||||
- 一次对话中上传图片、生成 HTML、发布页面后,package 页面能看到所有产物。
|
||||
- package manifest 可以导出,并能指向真实可读的 backing file。
|
||||
- 不再只能靠散落目录追查某次对话产生了什么。
|
||||
|
||||
### P3: MindSpace Service 本地独立进程
|
||||
|
||||
目标: 在本机开发环境先拆进程,不碰生产 103。
|
||||
|
||||
动作:
|
||||
|
||||
- MindSpace API 单独启动,例如 `localhost:<mindspace-port>`。
|
||||
- Memind App 通过 `MINDSPACE_API_BASE_URL` 调它。
|
||||
- Goose 仍可本地运行,但通过 MindSpace API/MCP 获取 workspace。
|
||||
- 本地 storage adapter 继续使用 local fs。
|
||||
|
||||
验收:
|
||||
|
||||
- 停掉 MindSpace Service 后,Memind H5 能明确报 MindSpace 不可用,而不是静默写本地路径。
|
||||
- 换一个 local storage root 后,Memind 和 Goose 不需要改业务代码。
|
||||
|
||||
### P4: MindSpace MCP Bridge
|
||||
|
||||
目标: Goose 不再把 raw filesystem path 当核心契约。
|
||||
|
||||
动作:
|
||||
|
||||
- MCP tools 改为接受 `workspaceRef` / `packageId`。
|
||||
- `read_file`、`write_file`、`edit_file`、`publish_page` 通过 MindSpace Service 落元数据。
|
||||
- scoped token 限制到用户、session、package 和允许工具。
|
||||
|
||||
验收:
|
||||
|
||||
- Agent 生成文件后,MindSpace package manifest 自动更新。
|
||||
- Policy 泄漏时也只能访问当前 package 范围。
|
||||
|
||||
### P5: NAS/S3 Adapter
|
||||
|
||||
目标: 存储后端可替换。
|
||||
|
||||
动作:
|
||||
|
||||
- 实现 NAS adapter 或 S3 adapter。
|
||||
- 增加 migration/backfill 工具,把现有文件迁移到新 storage prefix。
|
||||
- 增加校验脚本: DB record、manifest、object stat、public URL 一致。
|
||||
- 必要时先 shadow-read 或 dual-write,降低迁移风险。
|
||||
|
||||
验收:
|
||||
|
||||
- local fs 切到 NAS/S3 后,Memind App 不改代码。
|
||||
- Goose 不改业务契约。
|
||||
- 公开页、下载、预览、长图、package manifest 都正常。
|
||||
|
||||
### P6: 生产部署迁移
|
||||
|
||||
目标: 等本地和 staging 验证后,再考虑生产拓扑。
|
||||
|
||||
原则:
|
||||
|
||||
- 103/105 只是部署位置,不是架构边界。
|
||||
- 当前 103 可以继续承载旧 Portal 和 MindSpace,直到 MindSpace Service 独立进程稳定。
|
||||
- 生产迁移前必须有回滚方案、manifest 校验、public URL 抽样验证。
|
||||
- 不从本地直接依赖 103 做开发验证。
|
||||
|
||||
## 8. 当前 103/105 如何理解
|
||||
|
||||
如果短期还要用 103/105 描述生产部署,可以这样表述:
|
||||
|
||||
```text
|
||||
105 = public edge / Memind App entry
|
||||
103 = current production host running legacy Portal + MindSpace + Goose
|
||||
```
|
||||
|
||||
但这只是当前生产状态,不是最终架构。
|
||||
|
||||
最终应该允许:
|
||||
|
||||
```text
|
||||
Memind App: 105 / k8s / 任意 Web 服务
|
||||
MindSpace: 独立 VM / NAS 旁路服务 / S3-backed 服务 / k8s service
|
||||
Goose Runtime: 本机 / worker pool / 独立执行集群
|
||||
Storage Backend: local fs / NAS / S3 / CDN-backed object storage
|
||||
```
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
拆分成功不以“服务能启动”为准,而以这些行为为准:
|
||||
|
||||
1. 一次对话上传的图片、文件、生成页面、公开 HTML、长图都能在同一个 package 中看到。
|
||||
2. Package manifest 中的每个 artifact 都能追溯到 asset/page/publication/message/run。
|
||||
3. Memind App 不直接拼接 MindSpace 物理路径或 public URL。
|
||||
4. Goose 生成文件后,MindSpace 元数据和实际 backing file 一致。
|
||||
5. 切换 local fs / NAS / S3 adapter 时,Memind App 和 Goose 的业务逻辑不需要改。
|
||||
6. 公开 URL 由 MindSpace Service 生成并校验。
|
||||
7. 本地开发不依赖 103,生产 103 也不是架构上的必须组件。
|
||||
|
||||
## 10. 风险
|
||||
|
||||
| 风险 | 表现 | 控制方式 |
|
||||
| --- | --- | --- |
|
||||
| 路径假设太深 | 代码到处拼 `MindSpace/<userId>` | P0 做调用点清单,P1 加 service/adapter 边界 |
|
||||
| URL 权威混乱 | Memind、Goose、MindSpace 各自拼 URL | URL 只由 MindSpace Service 生成 |
|
||||
| Agent 直写绕过元数据 | 文件存在但 package 看不到 | MCP/API 写入后必须登记 artifact |
|
||||
| S3 非文件系统语义 | `edit_file`、stream、rename 行为不一致 | 先定义 adapter 能力,必要时用临时工作区 materialize |
|
||||
| 迁移丢历史关系 | 老页面找不到来源对话 | 利用现有 `source_session_id`、job session 做 best-effort 回填 |
|
||||
| 权限过宽 | agent token 访问其它用户文件 | scoped short-lived token,绑定 user/session/package |
|
||||
| package 过大 | 长对话产物太多 | manifest 分页、artifact 分类、归档策略 |
|
||||
| 生产切换风险 | public link 失效 | 迁移前跑 object stat + URL 抽样 + 回滚 |
|
||||
|
||||
## 11. 建议下一步
|
||||
|
||||
建议按这个顺序推进:
|
||||
|
||||
1. 保留本文档为总纲,另外新增 `docs/mindspace-service-contract.md` 写 API、storage adapter、package manifest 的详细契约。
|
||||
2. 做 P0 audit: 搜索所有 `MindSpace/`、`data/mindspace`、`sandboxRoot`、`/MindSpace/`、`canonicalUrl` 调用点。
|
||||
3. 先在单体内做 P1,不急着拆进程,避免一次同时改部署、存储、执行链路。
|
||||
4. 优先实现 P2 conversation package,因为这是用户可见价值,也能倒逼所有产物建立 provenance。
|
||||
5. 本地完成 P1/P2 smoke 后,再做 P3 独立 MindSpace Service。
|
||||
6. 等 P3 稳定后再考虑 P4 MCP 契约和 P5 NAS/S3。
|
||||
7. 生产 103/105 迁移放到最后,只作为部署迁移,不作为架构设计的出发点。
|
||||
|
||||
最小可交付版本:
|
||||
|
||||
```text
|
||||
单体内 MindSpaceService 边界
|
||||
+ local fs storage adapter
|
||||
+ conversation package tables/API
|
||||
+ H5 package view
|
||||
+ public URL 集中生成
|
||||
+ 现有发布/下载/预览回归通过
|
||||
```
|
||||
|
||||
这个版本即使还没有真正拆进程,也已经把未来独立部署、NAS/S3 和 Goose 解耦的地基打好了。
|
||||
|
||||
## 12. 启动门禁和回退策略
|
||||
|
||||
本轮推进只能在独立分支上开始,禁止直接提交 `main`。
|
||||
|
||||
当前建议分支:
|
||||
|
||||
```text
|
||||
codex/mindspace-decouple-20260702
|
||||
```
|
||||
|
||||
开始任何代码改动前必须先确认:
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
git status --short --branch
|
||||
git merge-base --is-ancestor origin/main HEAD
|
||||
```
|
||||
|
||||
通过条件:
|
||||
|
||||
- 当前分支不是 `main`。
|
||||
- 当前分支基于最新 `origin/main` 创建。
|
||||
- 工作区里只出现本轮 MindSpace 解耦相关文件。
|
||||
- 不允许从本机开发流程依赖 103。
|
||||
|
||||
本地保护:
|
||||
|
||||
- `.git/hooks/pre-commit` 应阻止在 `main` 上直接 commit。
|
||||
- 如果需要新分支,必须使用 `bash scripts/new-branch.sh <branch-name>`,不要直接在脏工作区切分支。
|
||||
- 每个阶段开始前先记录 `git status --short --branch`。
|
||||
|
||||
回退分三层:
|
||||
|
||||
1. 单文件回退: 如果某个文件改坏,只恢复该文件,不影响其它正在推进的内容。
|
||||
2. 阶段回退: P1/P2/P3 每个阶段独立提交;如果阶段失败,只 revert 该阶段提交。
|
||||
3. 分支回退: 如果方向整体不对,保留 `main` 不动,直接放弃 `codex/mindspace-decouple-20260702` 分支即可。
|
||||
|
||||
禁止事项:
|
||||
|
||||
- 禁止在 `main` 上 commit。
|
||||
- 禁止从脏工作区发布。
|
||||
- 禁止把 103 当作本地开发依赖。
|
||||
- 禁止在没有 package provenance 的情况下继续扩大文件生成链路。
|
||||
- 禁止把 storage backend 细节暴露成 Memind 或 Goose 的长期 API。
|
||||
|
||||
第一批可执行任务:
|
||||
|
||||
1. P0 audit: 生成路径、URL、policy、DB 调用点清单。
|
||||
2. 新增 `docs/mindspace-service-contract.md`,只写契约,不改运行逻辑。
|
||||
3. 设计 conversation package schema migration,但先不执行生产迁移。
|
||||
4. 在单体内抽 `MindSpaceStorageAdapter` 和 public URL 生成入口。
|
||||
5. 给现有发布/下载/预览跑回归,再进入下一阶段。
|
||||
@@ -0,0 +1,161 @@
|
||||
# MindSpace 解耦 P0 边界审计
|
||||
|
||||
日期: 2026-07-02
|
||||
|
||||
分支: `codex/mindspace-decouple-20260702`
|
||||
|
||||
目标:
|
||||
|
||||
- 找出当前代码中直接依赖 MindSpace 物理路径、公开 URL、agent sandbox policy、MindSpace DB 表的边界点。
|
||||
- 为后续 P1 `MindSpaceService`、P2 conversation package、P3 独立进程拆分提供改造顺序。
|
||||
- 本文只做审计和分层,不改变运行行为。
|
||||
|
||||
## 1. 审计结论
|
||||
|
||||
当前 MindSpace 已经具备资产、页面、发布、agent job 的基础模型,但边界仍混在 Portal 单体里:
|
||||
|
||||
- 物理 workspace 路径由 `user-publish.mjs`、`user-space.mjs`、`server.mjs`、`capabilities.mjs` 共同参与。
|
||||
- 公开 URL 由 `user-publish.mjs`、`mindspace-assets.mjs`、`mindspace-publications.mjs`、`server.mjs` 等处生成或修正。
|
||||
- Agent sandbox policy 仍以 `sandboxRoot` / `SANDBOX_ROOT` 为核心契约。
|
||||
- DB 层已有 `h5_page_records.source_session_id`、`h5_page_records.source_message_id`、`h5_agent_jobs.session_id`,但 `h5_assets` 还缺 conversation provenance。
|
||||
- 现有回归守卫重点保护 `public/*.html` materialize、Finish merge、用户消息内部前缀隐藏,这些路径后续改造时不能绕过。
|
||||
|
||||
架构判断:
|
||||
|
||||
- P1 先抽 `MindSpaceService` 和 storage adapter 边界,不拆进程。
|
||||
- P2 先落 conversation package 元数据和 manifest,不立即替换所有文件路径。
|
||||
- P3 以后再把 MindSpace Service 独立进程化。
|
||||
- 103/105 只作为生产部署说明,不作为开发或架构依赖。
|
||||
|
||||
## 2. 物理路径边界
|
||||
|
||||
核心文件:
|
||||
|
||||
| 文件 | 当前职责 | 风险 | P1 处理方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `user-publish.mjs` | `PUBLISH_ROOT_DIR = MindSpace`、用户发布目录、公开 URL helper、skill 约束文本 | URL 和路径耦合在同一层 | 保留兼容 helper,但新代码走 `MindSpaceService` URL/storage 接口 |
|
||||
| `user-space.mjs` | `MINDSPACE_STORAGE_ROOT`、workspace 分区、上传镜像、agent hints | workspace 和 asset storage 同时出现 | 把 storage root、workspace root 分离为 adapter 能力 |
|
||||
| `server.mjs` | watcher、static route、shared upload、public serving | Portal 直接掌握所有路径 | P1 先集中成 MindSpace route/service facade |
|
||||
| `mindspace-sandbox-mcp.mjs` | OS 层 `SANDBOX_ROOT` 文件操作 | Goose 以 raw fs path 作为契约 | P4 改成 `workspaceRef` / `packageId`,短期保留兼容 |
|
||||
| `capabilities.mjs` | 注入 sandbox MCP env 和 args | policy 直接依赖 `sandboxRoot` | 新增 policy adapter,避免业务层自己拼 path |
|
||||
|
||||
主要路径形态:
|
||||
|
||||
```text
|
||||
MindSpace/<userId>/public/*.html
|
||||
MindSpace/<userId>/oa/*
|
||||
data/mindspace/users/<userId>/*
|
||||
SANDBOX_ROOT=<absolute workspace path>
|
||||
MINDSPACE_STORAGE_ROOT=<absolute storage path>
|
||||
```
|
||||
|
||||
## 3. URL 权威边界
|
||||
|
||||
核心文件:
|
||||
|
||||
| 文件 | 当前职责 | 风险 | P1 处理方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `user-publish.mjs` | `buildPublicUrl()`、`buildPublicZonePageUrl()` | 调用方可能把 helper 当长期权威 | 标记为 legacy compatibility,新增 canonical URL service |
|
||||
| `mindspace-assets.mjs` | public temp image URL | asset public URL 与 storage key 绑定 | 改成 `MindSpaceService.createPublicAssetUrl()` |
|
||||
| `mindspace-publications.mjs` | publication response、public URL、标准图片替换 | 发布 URL 权威分散 | publication canonical URL 统一收口 |
|
||||
| `server.mjs` | `/MindSpace/*` route、redirect、shared upload public URL | Express route 同时做 URL 修复和文件服务 | route 仅调用 MindSpace service facade |
|
||||
| `mindspace-chat-save.mjs` | 修正聊天中的 MindSpace 链接 | 后续仍需兼容旧 URL | 保留兼容,新增 canonicalize API |
|
||||
|
||||
短期规则:
|
||||
|
||||
- 不删除旧 URL helper。
|
||||
- 新增代码不得直接拼 `/MindSpace/<userId>/...`。
|
||||
- 所有新 public URL 生成必须经过 MindSpace service facade。
|
||||
|
||||
## 4. Agent Policy 边界
|
||||
|
||||
核心文件:
|
||||
|
||||
| 文件 | 当前职责 | 风险 | P1/P4 处理方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| `capabilities.mjs` | 将 sandbox MCP 注入 Goose extension | policy 里暴露 raw path | 先增加 policy builder 边界,后续改 workspaceRef |
|
||||
| `mindspace-agent-runner.mjs` | agent job claim 后创建 goosed 运行上下文 | job 与 workspace/path/publish layout 混合 | job runner 改为请求 MindSpace workspace capability |
|
||||
| `user-space.mjs` | hints 中告诉 agent 目录和公开链接格式 | agent 直接学习物理路径 | hints 改为逻辑 workspace + 兼容说明 |
|
||||
| `mindspace-sandbox-mcp.mjs` | read/write/edit/create_dir | 只能处理 filesystem backend | 后续新增 MindSpace MCP bridge |
|
||||
|
||||
长期 policy 目标:
|
||||
|
||||
```json
|
||||
{
|
||||
"mindspace": {
|
||||
"workspaceRef": "mindspace://users/<userId>/conversations/<sessionId>",
|
||||
"packageId": "cp_xxx",
|
||||
"apiBaseUrl": "https://mindspace.example.com",
|
||||
"accessToken": "scoped-short-lived-token"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. DB 和 Provenance 边界
|
||||
|
||||
已有基础:
|
||||
|
||||
- `h5_assets`: asset 基础元数据,当前没有 `source_session_id`、`source_message_id`、`source_run_id`。
|
||||
- `h5_asset_versions`: `storage_key` 是当前 asset 到物理存储的主要桥。
|
||||
- `h5_page_records`: 已有 `source_session_id`、`source_message_id`、`source_asset_id`。
|
||||
- `h5_page_versions`: 已有 `source_snapshot_json`,可承载页面生成时的上下文。
|
||||
- `h5_publish_records`: 保存 `public_url`,是公开 URL 现有权威记录。
|
||||
- `h5_publication_asset_refs`: publication 与 asset 引用关系。
|
||||
- `h5_agent_jobs`: 已有 `session_id`、`result_page_id`、`result_asset_id`。
|
||||
- `h5_agent_runs`: 已有 `agent_session_id`、`request_id`。
|
||||
|
||||
缺口:
|
||||
|
||||
- 没有 conversation package 表。
|
||||
- 没有 package 与 asset/page/publication/job/message 的统一 artifact 关系表。
|
||||
- Asset 层没有完整对话来源,导致“这次对话生成了哪些文件”只能间接推断。
|
||||
|
||||
建议新增:
|
||||
|
||||
- `h5_conversation_packages`
|
||||
- `h5_conversation_artifacts`
|
||||
|
||||
过渡策略:
|
||||
|
||||
- 先通过 `h5_page_records.source_session_id` 和 `h5_agent_jobs.session_id` 回填页面/任务。
|
||||
- 新增 asset 时尽量携带 `session_id`、`message_id`、`run_id` 到 artifact 表。
|
||||
- 不急着修改旧 asset schema,优先用 junction table 降低迁移风险。
|
||||
|
||||
## 6. 回归守卫
|
||||
|
||||
后续改这些区域前必须跑:
|
||||
|
||||
```bash
|
||||
npm run verify:mindspace-publish-guards
|
||||
npm run verify:mindspace-publish-guards:full
|
||||
```
|
||||
|
||||
重点保护:
|
||||
|
||||
- `mindspace-public-finish-sync.mjs`: `edit_file` 必须 materialize 到 `public/*.html`。
|
||||
- `chat-finish-sync.mjs`: Finish / UpdateConversation 必须 merge,禁止盲覆盖。
|
||||
- `conversation-display.mjs` 和 `src/utils/message.ts`: 用户消息不得展示 routing/skill 内部前缀。
|
||||
- `src/hooks/useTKMindChat.ts`: `syncSessionMessages` merge + retry。
|
||||
- `server.mjs`: session snapshot 缓存必须保守。
|
||||
|
||||
## 7. 第一阶段落点
|
||||
|
||||
P1 最小代码目标:
|
||||
|
||||
- 新增 `MindSpaceStorageAdapter` 契约和 local fs adapter 包装层。
|
||||
- 新增 canonical public URL facade。
|
||||
- 新增 conversation package manifest 纯函数。
|
||||
- 先不替换现有 route,不改生产行为。
|
||||
|
||||
P2 最小产品目标:
|
||||
|
||||
- 为每个 chat session 懒创建 package。
|
||||
- 新上传文件、生成文件、页面、发布记录写入 artifact 归属。
|
||||
- H5 增加 package view。
|
||||
- 历史数据 best-effort 回填。
|
||||
|
||||
本轮已经开始的安全基线:
|
||||
|
||||
- 当前工作必须在 `codex/mindspace-decouple-20260702`。
|
||||
- `main` 本地 pre-commit 已阻止直接提交。
|
||||
- 不允许把 103 作为本地开发依赖。
|
||||
@@ -0,0 +1,261 @@
|
||||
# MindSpace Service Contract
|
||||
|
||||
日期: 2026-07-02
|
||||
|
||||
状态: Draft for P1/P2
|
||||
|
||||
目标:
|
||||
|
||||
- 定义 MindSpace 从 Memind Portal 单体拆出前必须稳定下来的 API、存储、URL、权限和 package 契约。
|
||||
- 允许先在单体内实现,再独立成进程。
|
||||
- 保证 Memind App 和 Goose Runtime 不依赖 MindSpace 的物理部署位置。
|
||||
|
||||
## 1. 边界原则
|
||||
|
||||
MindSpace Service 是以下对象的唯一权威:
|
||||
|
||||
- Space
|
||||
- Category
|
||||
- Asset
|
||||
- Asset Version
|
||||
- Page
|
||||
- Page Version
|
||||
- Publication
|
||||
- Conversation Package
|
||||
- Package Manifest
|
||||
- Canonical Public URL
|
||||
- Storage Key 到 backing object 的校验
|
||||
|
||||
Memind App 只调用 API,不直接读写 `MindSpace/` 或 `data/mindspace/`。
|
||||
|
||||
Goose Runtime 只通过 MindSpace API/MCP 获得 scoped workspace capability,不直接拥有 MindSpace 业务状态。
|
||||
|
||||
## 2. API Surface
|
||||
|
||||
建议从当前 `/api/mindspace/v1/*` 延续,先在单体内实现兼容 facade:
|
||||
|
||||
```text
|
||||
GET /api/mindspace/v1/spaces/current
|
||||
GET /api/mindspace/v1/assets
|
||||
POST /api/mindspace/v1/assets/uploads
|
||||
GET /api/mindspace/v1/assets/:assetId/download
|
||||
GET /api/mindspace/v1/pages
|
||||
POST /api/mindspace/v1/pages
|
||||
GET /api/mindspace/v1/pages/:pageId
|
||||
POST /api/mindspace/v1/pages/:pageId/publish
|
||||
GET /api/mindspace/v1/publications/:publicationId
|
||||
GET /api/mindspace/v1/conversation-packages
|
||||
POST /api/mindspace/v1/conversation-packages/ensure
|
||||
GET /api/mindspace/v1/conversation-packages/:packageId
|
||||
GET /api/mindspace/v1/conversation-packages/:packageId/manifest
|
||||
POST /api/mindspace/v1/conversation-packages/:packageId/artifacts
|
||||
```
|
||||
|
||||
所有响应必须包含稳定 ID,不把物理路径作为前端契约。
|
||||
|
||||
## 3. Storage Adapter
|
||||
|
||||
MindSpace Service 内部存储接口:
|
||||
|
||||
```ts
|
||||
interface MindSpaceStorageAdapter {
|
||||
putObject(key, body, options)
|
||||
getObject(key)
|
||||
statObject(key)
|
||||
deleteObject(key)
|
||||
listObjects(prefix, options)
|
||||
copyObject(sourceKey, targetKey, options)
|
||||
createReadStream(key)
|
||||
createWriteStream(key, options)
|
||||
getSignedUrl(key, options)
|
||||
}
|
||||
```
|
||||
|
||||
Adapter 要求:
|
||||
|
||||
- `key` 是相对 storage root 的逻辑 object key,禁止绝对路径。
|
||||
- local fs adapter 可以把 key 映射到磁盘。
|
||||
- NAS adapter 可以把 key 映射到共享挂载。
|
||||
- S3 adapter 可以把 key 映射到 object prefix。
|
||||
- 业务层不得依赖 adapter 的物理实现。
|
||||
|
||||
## 4. Canonical URL
|
||||
|
||||
URL 生成入口:
|
||||
|
||||
```ts
|
||||
createPublicPageUrl({ userId, publicationId, pageId, slug })
|
||||
createPublicAssetUrl({ userId, assetId, variant })
|
||||
canonicalizeMindSpaceUrl(inputUrl)
|
||||
validatePublicBackingObject({ publicationId })
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 新代码不得直接拼 `/MindSpace/<userId>/...`。
|
||||
- 旧 URL helper 暂时保留为 compatibility layer。
|
||||
- public URL 必须能反查到 publication 或 asset。
|
||||
- URL 返回前应校验 backing object 存在,或明确返回 pending 状态。
|
||||
|
||||
## 5. Conversation Package
|
||||
|
||||
Conversation package 是“每次对话文件夹包”的业务抽象。
|
||||
|
||||
逻辑 URI:
|
||||
|
||||
```text
|
||||
mindspace://users/<userId>/conversations/<sessionId>
|
||||
```
|
||||
|
||||
推荐 manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"packageId": "cp_xxx",
|
||||
"userId": "u_xxx",
|
||||
"sessionId": "s_xxx",
|
||||
"title": "对话标题",
|
||||
"storagePrefix": "users/u_xxx/conversations/s_xxx",
|
||||
"artifacts": [
|
||||
{
|
||||
"artifactId": "ca_xxx",
|
||||
"kind": "public_html",
|
||||
"assetId": "asset_xxx",
|
||||
"pageId": "page_xxx",
|
||||
"publicationId": "pub_xxx",
|
||||
"messageId": "msg_xxx",
|
||||
"agentRunId": "run_xxx",
|
||||
"displayName": "report.html",
|
||||
"mimeType": "text/html",
|
||||
"sizeBytes": 12345,
|
||||
"storageKey": "users/u_xxx/conversations/s_xxx/public/report.html",
|
||||
"canonicalUrl": "https://...",
|
||||
"createdAt": 1783000000000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Artifact kind:
|
||||
|
||||
```text
|
||||
input_image
|
||||
input_file
|
||||
generated_image
|
||||
generated_file
|
||||
page
|
||||
public_html
|
||||
long_image
|
||||
thumbnail
|
||||
docx
|
||||
pdf
|
||||
```
|
||||
|
||||
## 6. Metadata Tables
|
||||
|
||||
首选新增:
|
||||
|
||||
```sql
|
||||
CREATE TABLE h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) DEFAULT NULL,
|
||||
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) DEFAULT NULL,
|
||||
manifest_asset_id CHAR(36) DEFAULT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uniq_conversation_package_session (user_id, session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id CHAR(36) DEFAULT NULL,
|
||||
page_id CHAR(36) DEFAULT NULL,
|
||||
publication_id CHAR(36) DEFAULT NULL,
|
||||
agent_run_id CHAR(36) DEFAULT NULL,
|
||||
message_id VARCHAR(128) DEFAULT NULL,
|
||||
role VARCHAR(32) DEFAULT NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(255) DEFAULT NULL,
|
||||
storage_key VARCHAR(512) DEFAULT NULL,
|
||||
canonical_url VARCHAR(512) DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_conversation_artifacts_package (package_id),
|
||||
KEY idx_conversation_artifacts_asset (asset_id),
|
||||
KEY idx_conversation_artifacts_page (page_id),
|
||||
KEY idx_conversation_artifacts_message (message_id)
|
||||
);
|
||||
```
|
||||
|
||||
迁移原则:
|
||||
|
||||
- 先新增表,不改旧表语义。
|
||||
- 新写入链路双写 artifact 归属。
|
||||
- 历史数据 best-effort 回填。
|
||||
- 回填失败不影响旧页面访问。
|
||||
|
||||
## 7. Goose Capability
|
||||
|
||||
短期兼容:
|
||||
|
||||
```json
|
||||
{
|
||||
"sandboxRoot": "/absolute/local/MindSpace/<userId>",
|
||||
"serverPath": "/absolute/local/mindspace-sandbox-mcp.mjs"
|
||||
}
|
||||
```
|
||||
|
||||
长期目标:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspaceRef": "mindspace://users/<userId>/conversations/<sessionId>",
|
||||
"packageId": "cp_xxx",
|
||||
"apiBaseUrl": "https://mindspace.example.com",
|
||||
"accessToken": "short-lived-scoped-token",
|
||||
"allowedTools": ["read_file", "write_file", "edit_file", "publish_page"]
|
||||
}
|
||||
```
|
||||
|
||||
权限要求:
|
||||
|
||||
- Token 必须绑定 user、session、package。
|
||||
- Token 必须有过期时间。
|
||||
- Tool allowlist 必须由 Memind/MindSpace policy 共同决定。
|
||||
- Goose 不得凭 token 访问其它用户 package。
|
||||
|
||||
## 8. Acceptance Tests
|
||||
|
||||
P1:
|
||||
|
||||
- local fs adapter 拒绝绝对 key 和 `..` traversal。
|
||||
- canonical URL 只通过 facade 生成。
|
||||
- 旧 `MindSpace/<userId>/public/*.html` 链路不变。
|
||||
- `npm run verify:mindspace-publish-guards` 通过。
|
||||
|
||||
P2:
|
||||
|
||||
- 上传图片后 package manifest 出现 `input_image`。
|
||||
- 生成 HTML 后 package manifest 出现 `public_html`。
|
||||
- 发布页面后 artifact 关联 `pageId` 和 `publicationId`。
|
||||
- 同一 session 多次生成按 `sortOrder` 或 `createdAt` 稳定排序。
|
||||
|
||||
P3:
|
||||
|
||||
- MindSpace Service 停止时,Memind App 明确返回服务不可用。
|
||||
- 替换 local storage root 不需要改 Memind App / Goose 业务逻辑。
|
||||
|
||||
## 9. Non-Goals
|
||||
|
||||
当前阶段不做:
|
||||
|
||||
- 不迁移生产 103。
|
||||
- 不切换 NAS/S3。
|
||||
- 不删除旧 URL helper。
|
||||
- 不改变现有 `/MindSpace/*` 公开访问行为。
|
||||
- 不改变 Goose 当前可用的 `sandboxRoot` 兼容路径。
|
||||
@@ -0,0 +1,165 @@
|
||||
const PACKAGE_ID_PREFIX = 'cp_';
|
||||
const ARTIFACT_ID_PREFIX = 'ca_';
|
||||
|
||||
export const CONVERSATION_PACKAGE_SCHEMA_VERSION = 1;
|
||||
|
||||
export const CONVERSATION_ARTIFACT_KINDS = Object.freeze([
|
||||
'input_image',
|
||||
'input_file',
|
||||
'generated_image',
|
||||
'generated_file',
|
||||
'page',
|
||||
'public_html',
|
||||
'long_image',
|
||||
'thumbnail',
|
||||
'docx',
|
||||
'pdf',
|
||||
]);
|
||||
|
||||
function requiredString(value, field) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) {
|
||||
throw Object.assign(new Error(`${field} is required`), {
|
||||
code: 'invalid_conversation_package',
|
||||
field,
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function optionalString(value) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function safeStorageSegment(value, field) {
|
||||
const normalized = requiredString(value, field);
|
||||
const encoded = encodeURIComponent(normalized);
|
||||
if (encoded.includes('%2F') || encoded.includes('%5C')) {
|
||||
throw Object.assign(new Error(`${field} must not contain path separators`), {
|
||||
code: 'invalid_storage_segment',
|
||||
field,
|
||||
});
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
export function buildConversationStoragePrefix({ userId, sessionId }) {
|
||||
return `users/${safeStorageSegment(userId, 'userId')}/conversations/${safeStorageSegment(
|
||||
sessionId,
|
||||
'sessionId',
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function buildConversationPackageUri({ userId, sessionId }) {
|
||||
return `mindspace://users/${safeStorageSegment(userId, 'userId')}/conversations/${safeStorageSegment(
|
||||
sessionId,
|
||||
'sessionId',
|
||||
)}`;
|
||||
}
|
||||
|
||||
export function createConversationPackageRecord(input) {
|
||||
const userId = requiredString(input?.userId, 'userId');
|
||||
const sessionId = requiredString(input?.sessionId, 'sessionId');
|
||||
const now = Number.isFinite(Number(input?.now)) ? Number(input.now) : Date.now();
|
||||
const id = optionalString(input?.id) ?? `${PACKAGE_ID_PREFIX}${sessionId}`;
|
||||
const storagePrefix =
|
||||
optionalString(input?.storagePrefix) ?? buildConversationStoragePrefix({ userId, sessionId });
|
||||
|
||||
return {
|
||||
id,
|
||||
userId,
|
||||
sessionId,
|
||||
title: optionalString(input?.title),
|
||||
status: optionalString(input?.status) ?? 'active',
|
||||
storagePrefix,
|
||||
manifestAssetId: optionalString(input?.manifestAssetId),
|
||||
createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now,
|
||||
updatedAt: Number.isFinite(Number(input?.updatedAt)) ? Number(input.updatedAt) : now,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertConversationArtifactKind(kind) {
|
||||
const normalized = requiredString(kind, 'artifactKind');
|
||||
if (!CONVERSATION_ARTIFACT_KINDS.includes(normalized)) {
|
||||
throw Object.assign(new Error(`unsupported conversation artifact kind: ${normalized}`), {
|
||||
code: 'unsupported_conversation_artifact_kind',
|
||||
field: 'artifactKind',
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function createConversationArtifact(input) {
|
||||
const packageId = requiredString(input?.packageId, 'packageId');
|
||||
const artifactKind = assertConversationArtifactKind(input?.artifactKind ?? input?.kind);
|
||||
const now = Number.isFinite(Number(input?.now)) ? Number(input.now) : Date.now();
|
||||
const id = optionalString(input?.id) ?? `${ARTIFACT_ID_PREFIX}${packageId}_${artifactKind}_${now}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
packageId,
|
||||
artifactKind,
|
||||
role: optionalString(input?.role),
|
||||
assetId: optionalString(input?.assetId),
|
||||
pageId: optionalString(input?.pageId),
|
||||
publicationId: optionalString(input?.publicationId),
|
||||
agentRunId: optionalString(input?.agentRunId),
|
||||
messageId: optionalString(input?.messageId),
|
||||
displayName: optionalString(input?.displayName),
|
||||
mimeType: optionalString(input?.mimeType),
|
||||
sizeBytes: Number.isFinite(Number(input?.sizeBytes)) ? Number(input.sizeBytes) : null,
|
||||
storageKey: optionalString(input?.storageKey),
|
||||
canonicalUrl: optionalString(input?.canonicalUrl),
|
||||
sortOrder: Number.isFinite(Number(input?.sortOrder)) ? Number(input.sortOrder) : 0,
|
||||
createdAt: Number.isFinite(Number(input?.createdAt)) ? Number(input.createdAt) : now,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactManifestEntry(artifact) {
|
||||
return {
|
||||
artifactId: artifact.id,
|
||||
kind: artifact.artifactKind,
|
||||
...(artifact.role ? { role: artifact.role } : {}),
|
||||
...(artifact.assetId ? { assetId: artifact.assetId } : {}),
|
||||
...(artifact.pageId ? { pageId: artifact.pageId } : {}),
|
||||
...(artifact.publicationId ? { publicationId: artifact.publicationId } : {}),
|
||||
...(artifact.agentRunId ? { agentRunId: artifact.agentRunId } : {}),
|
||||
...(artifact.messageId ? { messageId: artifact.messageId } : {}),
|
||||
...(artifact.displayName ? { displayName: artifact.displayName } : {}),
|
||||
...(artifact.mimeType ? { mimeType: artifact.mimeType } : {}),
|
||||
...(artifact.sizeBytes !== null ? { sizeBytes: artifact.sizeBytes } : {}),
|
||||
...(artifact.storageKey ? { storageKey: artifact.storageKey } : {}),
|
||||
...(artifact.canonicalUrl ? { canonicalUrl: artifact.canonicalUrl } : {}),
|
||||
sortOrder: artifact.sortOrder,
|
||||
createdAt: artifact.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildConversationPackageManifest({ packageRecord, artifacts = [] }) {
|
||||
const record = createConversationPackageRecord(packageRecord);
|
||||
const normalizedArtifacts = artifacts
|
||||
.map((artifact) => createConversationArtifact({ ...artifact, packageId: record.id }))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
||||
|
||||
return {
|
||||
schemaVersion: CONVERSATION_PACKAGE_SCHEMA_VERSION,
|
||||
packageId: record.id,
|
||||
userId: record.userId,
|
||||
sessionId: record.sessionId,
|
||||
uri: buildConversationPackageUri({ userId: record.userId, sessionId: record.sessionId }),
|
||||
title: record.title,
|
||||
status: record.status,
|
||||
storagePrefix: record.storagePrefix,
|
||||
manifestAssetId: record.manifestAssetId,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: record.updatedAt,
|
||||
artifacts: normalizedArtifacts.map(artifactManifestEntry),
|
||||
};
|
||||
}
|
||||
|
||||
export const mindspaceConversationPackageInternals = {
|
||||
requiredString,
|
||||
optionalString,
|
||||
safeStorageSegment,
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertConversationArtifactKind,
|
||||
buildConversationPackageManifest,
|
||||
buildConversationPackageUri,
|
||||
buildConversationStoragePrefix,
|
||||
createConversationArtifact,
|
||||
createConversationPackageRecord,
|
||||
} from './mindspace-conversation-package.mjs';
|
||||
|
||||
test('buildConversationStoragePrefix produces backend-neutral object prefixes', () => {
|
||||
assert.equal(
|
||||
buildConversationStoragePrefix({ userId: 'user-1', sessionId: 'session-1' }),
|
||||
'users/user-1/conversations/session-1',
|
||||
);
|
||||
assert.equal(
|
||||
buildConversationPackageUri({ userId: 'user 1', sessionId: 'session:1' }),
|
||||
'mindspace://users/user%201/conversations/session%3A1',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildConversationStoragePrefix rejects path separators', () => {
|
||||
assert.throws(
|
||||
() => buildConversationStoragePrefix({ userId: '../user', sessionId: 'session-1' }),
|
||||
(error) => error.code === 'invalid_storage_segment',
|
||||
);
|
||||
assert.throws(
|
||||
() => buildConversationStoragePrefix({ userId: 'user-1', sessionId: 'a/b' }),
|
||||
(error) => error.code === 'invalid_storage_segment',
|
||||
);
|
||||
});
|
||||
|
||||
test('createConversationPackageRecord defaults storage prefix and timestamps', () => {
|
||||
assert.deepEqual(
|
||||
createConversationPackageRecord({
|
||||
id: 'cp_custom',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
title: '旅行页面',
|
||||
now: 1000,
|
||||
}),
|
||||
{
|
||||
id: 'cp_custom',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
title: '旅行页面',
|
||||
status: 'active',
|
||||
storagePrefix: 'users/user-1/conversations/session-1',
|
||||
manifestAssetId: null,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('createConversationArtifact validates artifact kind and preserves provenance', () => {
|
||||
assert.equal(assertConversationArtifactKind('public_html'), 'public_html');
|
||||
assert.throws(
|
||||
() => assertConversationArtifactKind('random_blob'),
|
||||
(error) => error.code === 'unsupported_conversation_artifact_kind',
|
||||
);
|
||||
|
||||
const artifact = createConversationArtifact({
|
||||
id: 'ca_1',
|
||||
packageId: 'cp_1',
|
||||
artifactKind: 'public_html',
|
||||
assetId: 'asset-1',
|
||||
pageId: 'page-1',
|
||||
publicationId: 'pub-1',
|
||||
agentRunId: 'run-1',
|
||||
messageId: 'msg-1',
|
||||
displayName: 'report.html',
|
||||
mimeType: 'text/html',
|
||||
sizeBytes: 120,
|
||||
storageKey: 'users/user-1/conversations/session-1/public/report.html',
|
||||
canonicalUrl: 'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
|
||||
sortOrder: 2,
|
||||
now: 2000,
|
||||
});
|
||||
|
||||
assert.equal(artifact.packageId, 'cp_1');
|
||||
assert.equal(artifact.artifactKind, 'public_html');
|
||||
assert.equal(artifact.assetId, 'asset-1');
|
||||
assert.equal(artifact.pageId, 'page-1');
|
||||
assert.equal(artifact.publicationId, 'pub-1');
|
||||
assert.equal(artifact.messageId, 'msg-1');
|
||||
});
|
||||
|
||||
test('buildConversationPackageManifest sorts artifacts and emits stable public shape', () => {
|
||||
const manifest = buildConversationPackageManifest({
|
||||
packageRecord: {
|
||||
id: 'cp_1',
|
||||
userId: 'user-1',
|
||||
sessionId: 'session-1',
|
||||
title: '项目周报',
|
||||
now: 1000,
|
||||
},
|
||||
artifacts: [
|
||||
{
|
||||
id: 'ca_late',
|
||||
artifactKind: 'generated_file',
|
||||
displayName: 'notes.md',
|
||||
sortOrder: 2,
|
||||
createdAt: 3000,
|
||||
},
|
||||
{
|
||||
id: 'ca_first',
|
||||
artifactKind: 'input_image',
|
||||
displayName: 'cover.png',
|
||||
sortOrder: 1,
|
||||
createdAt: 2000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.uri, 'mindspace://users/user-1/conversations/session-1');
|
||||
assert.equal(manifest.storagePrefix, 'users/user-1/conversations/session-1');
|
||||
assert.deepEqual(
|
||||
manifest.artifacts.map((artifact) => artifact.artifactId),
|
||||
['ca_first', 'ca_late'],
|
||||
);
|
||||
assert.deepEqual(manifest.artifacts[0], {
|
||||
artifactId: 'ca_first',
|
||||
kind: 'input_image',
|
||||
displayName: 'cover.png',
|
||||
sortOrder: 1,
|
||||
createdAt: 2000,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
const schemaSql = fs.readFileSync(new URL('./schema.sql', import.meta.url), 'utf8');
|
||||
const dbSource = fs.readFileSync(new URL('./db.mjs', import.meta.url), 'utf8');
|
||||
|
||||
function indexOfTable(source, tableName) {
|
||||
return source.indexOf(`CREATE TABLE IF NOT EXISTS ${tableName}`);
|
||||
}
|
||||
|
||||
test('schema.sql creates conversation package tables after their dependencies', () => {
|
||||
const users = indexOfTable(schemaSql, 'h5_users');
|
||||
const assets = indexOfTable(schemaSql, 'h5_assets');
|
||||
const pages = indexOfTable(schemaSql, 'h5_page_records');
|
||||
const publications = indexOfTable(schemaSql, 'h5_publish_records');
|
||||
const runs = indexOfTable(schemaSql, 'h5_agent_runs');
|
||||
const runEvents = indexOfTable(schemaSql, 'h5_agent_run_events');
|
||||
const packages = indexOfTable(schemaSql, 'h5_conversation_packages');
|
||||
const artifacts = indexOfTable(schemaSql, 'h5_conversation_artifacts');
|
||||
|
||||
for (const [name, index] of Object.entries({
|
||||
users,
|
||||
assets,
|
||||
pages,
|
||||
publications,
|
||||
runs,
|
||||
runEvents,
|
||||
packages,
|
||||
artifacts,
|
||||
})) {
|
||||
assert.notEqual(index, -1, `${name} table should exist in schema.sql`);
|
||||
}
|
||||
|
||||
assert.ok(packages > users, 'packages table should be after h5_users');
|
||||
assert.ok(packages > assets, 'packages table should be after h5_assets');
|
||||
assert.ok(artifacts > packages, 'artifacts table should be after packages');
|
||||
assert.ok(artifacts > pages, 'artifacts table should be after pages');
|
||||
assert.ok(artifacts > publications, 'artifacts table should be after publications');
|
||||
assert.ok(artifacts > runs, 'artifacts table should be after runs');
|
||||
assert.ok(artifacts > runEvents, 'artifacts table should be after run events');
|
||||
});
|
||||
|
||||
test('runtime schema migration keeps conversation package tables additive', () => {
|
||||
assert.match(dbSource, /CREATE TABLE IF NOT EXISTS h5_conversation_packages/);
|
||||
assert.match(dbSource, /CREATE TABLE IF NOT EXISTS h5_conversation_artifacts/);
|
||||
assert.match(dbSource, /uq_h5_conversation_package_session/);
|
||||
assert.match(dbSource, /fk_h5_conversation_artifact_package/);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
function storageError(message, code, details = {}) {
|
||||
return Object.assign(new Error(message), { code, ...details });
|
||||
}
|
||||
|
||||
export function normalizeMindSpaceStorageKey(key) {
|
||||
const raw = String(key ?? '').trim();
|
||||
if (!raw) {
|
||||
throw storageError('storage key is required', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.includes('\0')) {
|
||||
throw storageError('storage key must not contain null bytes', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.includes('\\')) {
|
||||
throw storageError('storage key must use forward slashes', 'invalid_storage_key');
|
||||
}
|
||||
if (path.isAbsolute(raw) || /^[A-Za-z]:\//.test(raw)) {
|
||||
throw storageError('storage key must be relative', 'invalid_storage_key');
|
||||
}
|
||||
if (raw.split('/').includes('..')) {
|
||||
throw storageError('storage key must not traverse parent directories', 'invalid_storage_key');
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(raw.replace(/^\/+/, ''));
|
||||
if (normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
|
||||
throw storageError('storage key must not traverse parent directories', 'invalid_storage_key');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveStoragePath(storageRoot, key) {
|
||||
const root = path.resolve(String(storageRoot ?? ''));
|
||||
if (!root) {
|
||||
throw storageError('storage root is required', 'invalid_storage_root');
|
||||
}
|
||||
const normalizedKey = normalizeMindSpaceStorageKey(key);
|
||||
const target = path.resolve(root, ...normalizedKey.split('/'));
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw storageError('storage key resolves outside storage root', 'invalid_storage_key', {
|
||||
key: normalizedKey,
|
||||
});
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function walkFiles(root, dir, prefix, results) {
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
||||
if (entry.isDirectory()) {
|
||||
await walkFiles(root, absolutePath, prefix, results);
|
||||
} else if (!prefix || relativePath === prefix || relativePath.startsWith(`${prefix}/`)) {
|
||||
const stat = await fsp.stat(absolutePath);
|
||||
results.push({
|
||||
key: relativePath,
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalMindSpaceStorageAdapter(storageRoot) {
|
||||
const root = path.resolve(storageRoot);
|
||||
|
||||
return {
|
||||
kind: 'local-fs',
|
||||
root,
|
||||
|
||||
async putObject(key, body) {
|
||||
const target = resolveStoragePath(root, key);
|
||||
await fsp.mkdir(path.dirname(target), { recursive: true });
|
||||
await fsp.writeFile(target, body);
|
||||
const stat = await fsp.stat(target);
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
};
|
||||
},
|
||||
|
||||
async getObject(key) {
|
||||
return fsp.readFile(resolveStoragePath(root, key));
|
||||
},
|
||||
|
||||
async statObject(key) {
|
||||
try {
|
||||
const stat = await fsp.stat(resolveStoragePath(root, key));
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
exists: true,
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(key),
|
||||
exists: false,
|
||||
sizeBytes: 0,
|
||||
updatedAt: null,
|
||||
isDirectory: false,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async deleteObject(key) {
|
||||
await fsp.rm(resolveStoragePath(root, key), { force: true });
|
||||
return { key: normalizeMindSpaceStorageKey(key), deleted: true };
|
||||
},
|
||||
|
||||
async listObjects(prefix = '') {
|
||||
const normalizedPrefix = prefix ? normalizeMindSpaceStorageKey(prefix).replace(/\/$/, '') : '';
|
||||
const results = [];
|
||||
try {
|
||||
await walkFiles(root, root, normalizedPrefix, results);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return results.sort((a, b) => a.key.localeCompare(b.key));
|
||||
},
|
||||
|
||||
async copyObject(sourceKey, targetKey) {
|
||||
const source = resolveStoragePath(root, sourceKey);
|
||||
const target = resolveStoragePath(root, targetKey);
|
||||
await fsp.mkdir(path.dirname(target), { recursive: true });
|
||||
await fsp.copyFile(source, target);
|
||||
const stat = await fsp.stat(target);
|
||||
return {
|
||||
key: normalizeMindSpaceStorageKey(targetKey),
|
||||
sourceKey: normalizeMindSpaceStorageKey(sourceKey),
|
||||
sizeBytes: stat.size,
|
||||
updatedAt: stat.mtimeMs,
|
||||
};
|
||||
},
|
||||
|
||||
createReadStream(key) {
|
||||
return fs.createReadStream(resolveStoragePath(root, key));
|
||||
},
|
||||
|
||||
createWriteStream(key) {
|
||||
const target = resolveStoragePath(root, key);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
return fs.createWriteStream(target);
|
||||
},
|
||||
|
||||
async getSignedUrl() {
|
||||
throw storageError('local fs adapter does not support signed URLs', 'signed_url_not_supported');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const mindspaceStorageAdapterInternals = {
|
||||
storageError,
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createLocalMindSpaceStorageAdapter,
|
||||
normalizeMindSpaceStorageKey,
|
||||
resolveStoragePath,
|
||||
} from './mindspace-storage-adapter.mjs';
|
||||
|
||||
test('normalizeMindSpaceStorageKey rejects absolute paths and traversal', () => {
|
||||
assert.equal(normalizeMindSpaceStorageKey('users/u1/file.txt'), 'users/u1/file.txt');
|
||||
assert.equal(normalizeMindSpaceStorageKey('users/u1/./file.txt'), 'users/u1/file.txt');
|
||||
|
||||
for (const key of ['/tmp/file.txt', '../file.txt', 'users/../file.txt', 'C:/tmp/file.txt', 'a\\b']) {
|
||||
assert.throws(
|
||||
() => normalizeMindSpaceStorageKey(key),
|
||||
(error) => error.code === 'invalid_storage_key',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveStoragePath keeps object keys inside storage root', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-storage-root-'));
|
||||
const target = resolveStoragePath(root, 'users/u1/file.txt');
|
||||
|
||||
assert.equal(target, path.join(root, 'users', 'u1', 'file.txt'));
|
||||
assert.throws(
|
||||
() => resolveStoragePath(root, '../outside.txt'),
|
||||
(error) => error.code === 'invalid_storage_key',
|
||||
);
|
||||
});
|
||||
|
||||
test('local storage adapter writes, reads, stats, lists, copies, and deletes objects', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-storage-adapter-'));
|
||||
const adapter = createLocalMindSpaceStorageAdapter(root);
|
||||
|
||||
const writeResult = await adapter.putObject('users/u1/conversations/s1/public/report.html', '<h1>Hi</h1>');
|
||||
assert.equal(writeResult.key, 'users/u1/conversations/s1/public/report.html');
|
||||
assert.equal(writeResult.sizeBytes, Buffer.byteLength('<h1>Hi</h1>'));
|
||||
|
||||
const body = await adapter.getObject('users/u1/conversations/s1/public/report.html');
|
||||
assert.equal(body.toString('utf8'), '<h1>Hi</h1>');
|
||||
|
||||
const stat = await adapter.statObject('users/u1/conversations/s1/public/report.html');
|
||||
assert.equal(stat.exists, true);
|
||||
assert.equal(stat.isDirectory, false);
|
||||
|
||||
await adapter.copyObject(
|
||||
'users/u1/conversations/s1/public/report.html',
|
||||
'users/u1/conversations/s1/generated/report-copy.html',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
(await adapter.listObjects('users/u1/conversations/s1')).map((item) => item.key),
|
||||
[
|
||||
'users/u1/conversations/s1/generated/report-copy.html',
|
||||
'users/u1/conversations/s1/public/report.html',
|
||||
],
|
||||
);
|
||||
|
||||
await adapter.deleteObject('users/u1/conversations/s1/public/report.html');
|
||||
assert.equal((await adapter.statObject('users/u1/conversations/s1/public/report.html')).exists, false);
|
||||
});
|
||||
|
||||
test('local storage adapter rejects signed URLs explicitly', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-storage-adapter-'));
|
||||
const adapter = createLocalMindSpaceStorageAdapter(root);
|
||||
|
||||
await assert.rejects(
|
||||
() => adapter.getSignedUrl('users/u1/file.txt'),
|
||||
(error) => error.code === 'signed_url_not_supported',
|
||||
);
|
||||
});
|
||||
@@ -20,11 +20,12 @@ test('buildEditablePreviewDocument injects visual editor into body', () => {
|
||||
assert.match(result, /isProtectedNode/);
|
||||
});
|
||||
|
||||
test('buildEditablePreviewDocument normalizes platform brand contact line', () => {
|
||||
test('buildEditablePreviewDocument normalizes platform brand line', () => {
|
||||
const html = '<!doctype html><html><body><p>📧 contact@tkmind.ai</p></body></html>';
|
||||
const result = buildEditablePreviewDocument(html);
|
||||
assert.match(result, /contact@tkmind\.cn/);
|
||||
assert.match(result, /TKMind · 智趣/);
|
||||
assert.match(result, /data-mindspace-page-tag="platform-brand"/);
|
||||
assert.doesNotMatch(result, /contact@tkmind\.cn/i);
|
||||
assert.doesNotMatch(result, /tkmind\.ai/i);
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
"build:portal-runtime": "node scripts/build-portal-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",
|
||||
"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-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.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-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-schema.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",
|
||||
"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",
|
||||
|
||||
+46
@@ -386,6 +386,52 @@ CREATE TABLE IF NOT EXISTS h5_agent_run_events (
|
||||
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_packages (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
session_id VARCHAR(128) NOT NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active',
|
||||
storage_prefix VARCHAR(512) NULL,
|
||||
manifest_asset_id CHAR(36) NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE KEY uq_h5_conversation_package_session (user_id, session_id),
|
||||
KEY idx_h5_conversation_package_user_updated (user_id, updated_at),
|
||||
CONSTRAINT fk_h5_conversation_package_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_package_manifest FOREIGN KEY (manifest_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_conversation_artifacts (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
package_id VARCHAR(64) NOT NULL,
|
||||
asset_id CHAR(36) NULL,
|
||||
page_id CHAR(36) NULL,
|
||||
publication_id CHAR(36) NULL,
|
||||
agent_run_id CHAR(36) NULL,
|
||||
message_id VARCHAR(128) NULL,
|
||||
role VARCHAR(32) NULL,
|
||||
artifact_kind VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(255) NULL,
|
||||
mime_type VARCHAR(128) NULL,
|
||||
size_bytes BIGINT NULL,
|
||||
storage_key VARCHAR(512) NULL,
|
||||
canonical_url VARCHAR(512) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
KEY idx_h5_conversation_artifact_package (package_id, sort_order, created_at),
|
||||
KEY idx_h5_conversation_artifact_asset (asset_id),
|
||||
KEY idx_h5_conversation_artifact_page (page_id),
|
||||
KEY idx_h5_conversation_artifact_publication (publication_id),
|
||||
KEY idx_h5_conversation_artifact_run (agent_run_id),
|
||||
KEY idx_h5_conversation_artifact_message (message_id),
|
||||
CONSTRAINT fk_h5_conversation_artifact_package FOREIGN KEY (package_id) REFERENCES h5_conversation_packages(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_h5_conversation_artifact_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_h5_conversation_artifact_run FOREIGN KEY (agent_run_id) REFERENCES h5_agent_runs(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS h5_session_billing_state (
|
||||
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
user_id CHAR(36) NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user