diff --git a/db.mjs b/db.mjs index cfb5d89..8b6f8a2 100644 --- a/db.mjs +++ b/db.mjs @@ -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, diff --git a/docs/memind-control-execution-split-plan-20260702.md b/docs/memind-control-execution-split-plan-20260702.md new file mode 100644 index 0000000..4755fa5 --- /dev/null +++ b/docs/memind-control-execution-split-plan-20260702.md @@ -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/` 路径作为产品级契约 +``` + +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//...` + - 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//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//conversations// + 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:`。 +- 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/` | 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 `,不要直接在脏工作区切分支。 +- 每个阶段开始前先记录 `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. 给现有发布/下载/预览跑回归,再进入下一阶段。 diff --git a/docs/mindspace-boundary-audit-20260702.md b/docs/mindspace-boundary-audit-20260702.md new file mode 100644 index 0000000..7db5e36 --- /dev/null +++ b/docs/mindspace-boundary-audit-20260702.md @@ -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//public/*.html +MindSpace//oa/* +data/mindspace/users//* +SANDBOX_ROOT= +MINDSPACE_STORAGE_ROOT= +``` + +## 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//...`。 +- 所有新 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//conversations/", + "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 作为本地开发依赖。 diff --git a/docs/mindspace-service-contract.md b/docs/mindspace-service-contract.md new file mode 100644 index 0000000..56a4399 --- /dev/null +++ b/docs/mindspace-service-contract.md @@ -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//...`。 +- 旧 URL helper 暂时保留为 compatibility layer。 +- public URL 必须能反查到 publication 或 asset。 +- URL 返回前应校验 backing object 存在,或明确返回 pending 状态。 + +## 5. Conversation Package + +Conversation package 是“每次对话文件夹包”的业务抽象。 + +逻辑 URI: + +```text +mindspace://users//conversations/ +``` + +推荐 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/", + "serverPath": "/absolute/local/mindspace-sandbox-mcp.mjs" +} +``` + +长期目标: + +```json +{ + "workspaceRef": "mindspace://users//conversations/", + "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//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` 兼容路径。 diff --git a/mindspace-conversation-package.mjs b/mindspace-conversation-package.mjs new file mode 100644 index 0000000..ecdf09f --- /dev/null +++ b/mindspace-conversation-package.mjs @@ -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, +}; diff --git a/mindspace-conversation-package.test.mjs b/mindspace-conversation-package.test.mjs new file mode 100644 index 0000000..65c1bed --- /dev/null +++ b/mindspace-conversation-package.test.mjs @@ -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, + }); +}); diff --git a/mindspace-conversation-schema.test.mjs b/mindspace-conversation-schema.test.mjs new file mode 100644 index 0000000..481fc04 --- /dev/null +++ b/mindspace-conversation-schema.test.mjs @@ -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/); +}); diff --git a/mindspace-storage-adapter.mjs b/mindspace-storage-adapter.mjs new file mode 100644 index 0000000..1bec7de --- /dev/null +++ b/mindspace-storage-adapter.mjs @@ -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, +}; diff --git a/mindspace-storage-adapter.test.mjs b/mindspace-storage-adapter.test.mjs new file mode 100644 index 0000000..9528118 --- /dev/null +++ b/mindspace-storage-adapter.test.mjs @@ -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', '

Hi

'); + assert.equal(writeResult.key, 'users/u1/conversations/s1/public/report.html'); + assert.equal(writeResult.sizeBytes, Buffer.byteLength('

Hi

')); + + const body = await adapter.getObject('users/u1/conversations/s1/public/report.html'); + assert.equal(body.toString('utf8'), '

Hi

'); + + 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', + ); +}); diff --git a/mindspace-visual-editor.test.mjs b/mindspace-visual-editor.test.mjs index eb3d52d..c4791e3 100644 --- a/mindspace-visual-editor.test.mjs +++ b/mindspace-visual-editor.test.mjs @@ -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 = '

📧 contact@tkmind.ai

'; 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); }); diff --git a/package.json b/package.json index 885a81e..af7b50c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schema.sql b/schema.sql index cd0505e..21bfa7c 100644 --- a/schema.sql +++ b/schema.sql @@ -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,