Files
CherryHQ-cherry-studio/v2-refactor-temp/docs/ai/data-cluster.md
jd 29286cad38 refactor(agent-session): make workspace binding explicit (#15736)
Co-authored-by: SuYao <sy20010504@gmail.com>
Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com>
Signed-off-by: jdzhang <625013594@qq.com>
Signed-off-by: zhangjiadi225 <625013594@qq.com>
2026-06-09 17:50:31 +08:00

17 KiB

Data Layer — Reviewer Cluster

Scope

Subpath What changed
src/main/data/db/schemas/ Agent / session / workspace / agent-message tables restructured
src/main/data/services/ AgentSessionService.ts + AgentWorkspaceService.ts new; AgentService.ts, AgentSessionMessageService.ts, MessageService.ts heavy rewrites
src/main/data/api/handlers/ agentSessions.ts + workspace handlers new; agents.ts slimmed (~100 LOC); messages.ts extended; assistants.ts + topics.ts extended
src/main/data/migration/v2/migrators/ AgentsMigrator.ts + AgentsDbMappings.ts rewrites; ChatMigrator.ts parts conversion; ProviderModelMigrator.ts adapterFamily backfill
src/shared/data/types/ agentMessage.ts + uiParts.ts new; agent.ts slimmed via Zod inference; message.ts heavy rewrite (parts model)
src/shared/agents/ agentSlashCommands.ts new (builtin SDK command list, off the data layer)
src/shared/data/api/schemas/ agent session + workspace schemas new; agents.ts slimmed by 126 LOC; messages.ts + assistants.ts + providers.ts extended

Total surface: ~94 files modified across src/main/data/ and src/shared/data/.

Intent

The AI pipeline refactor exposed three v1 data-layer constraints:

  1. Agent and Session conflated cognitive config. Each v1 session carried its own copy of mcps / allowedTools / configuration / accessible paths. Two sessions of the same agent could drift, and the renderer had to chase both rows to render an agent profile.
  2. Workspace was a string column on agents. A single accessible_paths: string[] field on each agent. Sessions inherited it. There was no row identity for "this directory" — renaming, reordering, or reusing a directory across agents meant duplicating the string.
  3. Agent session messages stored blocks: MessageBlock[] — Cherry v1's custom block model. AI SDK v6 produces UIMessage.parts directly. Continuing to convert one to the other on every read / write meant maintaining a translation layer that has zero v2-side consumers.

The refactor:

  • Split. agent keeps cognitive config; agent_session keeps per-session state + workspace binding. Sessions reference their agent by FK with onDelete: 'set null' so orphan sessions can still render.
  • Normalize. agent_workspace is a separate table with unique path index and a type discriminator (user or system). Sessions FK to workspaces with onDelete: 'cascade'; deleting a workspace row deletes sessions bound to it.
  • Parts. agent_session_message.content stores AgentPersistedMessage (AI-SDK-native parts) directly. The blocks field is gone end-to-end; the migrator converts legacy rows in place.

See Multi-model & data shape changes below for the multi-model + plan/small model split that motivated the agent schema rewrite.

Schema diff (production-shape)

agent (rewritten — src/main/data/db/schemas/agent.ts)

export const agentTable = sqliteTable('agent', {
  id: uuidPrimaryKey(),
  type: text().notNull(),                                         // 'claude-code', future agent kinds
  name: text().notNull(),
  description: text().notNull().default(''),
  instructions: text().notNull(),

  // Multi-model: chat / plan / small as three separate FK columns to userModel.
  // Plan model = used for high-level planning when the agent supports it;
  // small model = used for cheap helper calls (compaction, summaries).
  model:      text().references(() => userModelTable.id, { onDelete: 'set null' }),
  planModel:  text().references(() => userModelTable.id, { onDelete: 'set null' }),
  smallModel: text().references(() => userModelTable.id, { onDelete: 'set null' }),

  mcps:          text({ mode: 'json' }).$type<string[]>().notNull().default('[]'),
  allowedTools:  text({ mode: 'json' }).$type<string[]>().notNull().default('[]'),
  configuration: text({ mode: 'json' }).$type<Record<string, unknown>>().notNull().default('{}'),
  ...orderKeyColumns,
  ...createUpdateDeleteTimestamps
})

Removed from this table (relative to v1): accessible_paths, per-session config fields, enableAutoTools, model ids as strings.

configuration is intentionally .loose() (Zod passthrough) at the schema layer — see AgentConfigurationSchema for the typed keys (avatar, permission_mode, max_turns, scheduler_, heartbeat_, soul_enabled, env_vars). Unknown extras are preserved across read/write so older /newer app versions don't silently drop fields.

agent_session (rewritten — agentSession.ts)

export const agentSessionTable = sqliteTable('agent_session', {
  id: uuidPrimaryKey(),
  agentId:     text().references(() => agentTable.id,     { onDelete: 'set null' }),
  name: text().notNull(),
  description: text().notNull().default(''),
  workspaceId: text()
    .notNull()
    .references(() => agentWorkspaceTable.id, { onDelete: 'cascade' }),
  ...orderKeyColumns,
  ...createUpdateTimestamps
})

Removed (relative to v1): every cognitive-config field. The renderer fetches them via useAgent(session.agentId).

Insert-only workspace. UpdateSessionDto deliberately does not include workspaceId — a running session can't be re-pointed at a new directory. Newly created sessions must bind an explicit workspace source: either { type: 'user', workspaceId } for an existing user workspace, or { type: 'system' } for a deterministic app-owned system workspace row.

agent_workspace (new — agentWorkspace.ts)

export const agentWorkspaceTable = sqliteTable('agent_workspace', {
  id: uuidPrimaryKey(),
  name: text().notNull(),
  path: text().notNull(),
  type: text().notNull(), // CHECK type IN ('user', 'system')
  ...orderKeyColumns,
  ...createUpdateTimestamps
}, t => [
  uniqueIndex('agent_workspace_path_unique_idx').on(t.path),
  check('agent_workspace_type_check', sql`${t.type} IN (...)`),
  ...
])

path is the unique key. AgentWorkspaceService is DB-only: it normalizes paths, creates/reuses user workspace rows, creates deterministic system workspace rows, and never touches the filesystem. Filesystem directory validation and creation live on the Claude Code runtime consumer for user workspaces, and only app-owned system workspace directories are auto-created.

agent_session_message (rewritten — agentSessionMessage.ts)

export const agentSessionMessageTable = sqliteTable('agent_session_message', {
  id: uuidPrimaryKeyOrdered(),
  sessionId: text().notNull().references(() => agentSessionTable.id, { onDelete: 'cascade' }),
  role: text().notNull(),
  content: text({ mode: 'json' }).$type<AgentPersistedMessage>().notNull(),
  // Opaque runtime resume token; null when the session never ran or was reset.
  runtimeResumeToken: text(),
  metadata: text({ mode: 'json' }).$type<Record<string, unknown>>(),
  ...createUpdateTimestamps
})

content is now AgentPersistedMessage (parts model). The legacy blocks column is gone. runtimeResumeToken is an opaque recovery token owned by the active agent runtime driver; Claude Code maps it to its SDK session id, while other drivers may use a different resume token or none.

agent_channel + agent_channel_task (new — agentChannel.ts)

Channel adapters for Discord / Slack / Telegram / Feishu / WeChat / QQ. config is JSON, permissionMode is constrained via check to the Claude Agent SDK's permission modes, and agent_channel_task is the join table to scheduled agent_task rows.

agent_task, agent_task_run_log, agent_global_skill, agent_skill

Agent-domain scheduled-task storage. Each agent_task carries cron / interval / one-time scheduling plus a prompt; agent_task_run_log stores per-run outputs. agent_global_skill + agent_skill model the (currently flat) "skill" catalog.

Relationship to the generic job / job_schedule stack

job and job_schedule are the generic scheduling backbone owned by JobManager + SchedulerService + JobService / JobScheduleService. They are not removed — they were added on origin/v2 in parallel with this branch's agent work and arrived here via the v2 merge. See docs/references/job-and-scheduler/ for the design.

The agent_task tables are layered ABOVE the job stack — the agent scheduler will register typed handlers against JobManager and store its own task metadata in agent_task. Reviewing changes here should treat the two as complementary, not as a replacement.

Service-layer changes

New services

  • AgentSessionService.ts. Cursor-paginated list with order keys, transactional create that requires an explicit workspace source. User sources must reference user workspace rows; system sources create a deterministic session-owned workspace row. Workspace binding remains insert-only.
  • AgentWorkspaceService.ts. DB-only workspace row access, path normalization, find-or-create for user workspace rows, deterministic system workspace row creation, and reorder. It owns no filesystem side effects; runtime consumers validate user directories and create only app-owned system directories when needed.

Heavy rewrites

  • AgentService.ts (+241 LOC change). Foreign-keyed model fields expand into joined reads; cognitive config snapshot (getAgentForRun(sessionId)) consolidates fields the AI pipeline needs. See commit f2229a881 refactor(agents): harden agent model field to UniqueModelId end-to-end.
  • AgentSessionMessageService.ts (+215 LOC). Cursor-paginated history (newest-first); persists AgentPersistedMessage; idempotent upsert keyed on (sessionId, content.id) so retried persistence doesn't double-insert.
  • MessageService.ts (+390 LOC). Tree operations under the v2 parts model — createUserMessageWithPlaceholders (transactional), tree path reads, sibling groups, branch active-path tracking. Drops every blocks reference.

DataApi handlers

Endpoint Status Notes
GET/POST /agent-sessions, /agent-sessions/:id new session CRUD
GET /agent-sessions/:id/messages new cursor-paginated
GET /agent-workspaces, /agent-workspaces/:id, reorder endpoints new workspace reads + reorder
/agents/* slimmed ~100 LOC removed; legacy order endpoints gone
/messages/* extended parts read/write, tree path, sibling helpers
/topics/* extended branch-aware active-node tracking
/jobs/*, /job_schedules/* added on origin/v2 (out of scope here) see docs/references/job-and-scheduler/

Migration (v1 → v2)

AgentsMigrator

Reads the legacy standalone agents.db and folds it into the main SQLite database. Source tables → targets:

Source Target
agents agent (+ join: model id → user_model)
sessions agent_session
sessions.accessible_paths[0] agent_workspace (one workspace per session, first valid path)
session_messages agent_session_message (with transformBlocksToParts)
skills agent_global_skill
agent_skills agent_skill
scheduled_tasks agent_task
task_run_logs agent_task_run_log
channels agent_channel
channel_task_subscriptions agent_channel_task

Key points:

  • First workspace wins. Only accessible_paths[0] is migrated to a workspace row. Additional paths are not preserved. See 2026-05-19-agent-session-primary-workspace.md.
  • blocksparts for legacy session messages, via the same transformBlocksToParts that ChatMigrator uses for the chat tree.
  • Defensive default backfill. notNullCol(name, defaultExpr) in AgentsDbMappings covers the case where legacy rows have NULL in columns that are NOT NULL in v2; a plain SELECT col would otherwise hit SQLITE_CONSTRAINT_NOTNULL.
  • Order keys. generateOrderKeySequence synthesizes fractional-indexing order keys for every migrated row so the v2 reorder UX works on migrated data.

ChatMigrator

The transformBlocksToParts helper is shared with AgentsMigrator. Both produce CherryMessagePart[]; no legacy blocks survives the migration.

ProviderModelMigrator

Backfills adapterFamily per endpoint config. See adapter-family.md.

Shared types & API schemas

src/shared/data/types/agentMessage.ts (new)

The AgentPersistedMessage shape stored on agent_session_message.content.

src/shared/data/types/uiParts.ts (new)

Lifted the UI part type definitions out of message.ts so the agents domain can consume them without taking the full chat-message dependency graph.

src/shared/data/types/agent.ts (slimmed)

Replaced hand-written types with Zod-inferred types from api/schemas/agents.ts.

src/shared/data/types/message.ts (rewritten)

Removed the legacy blocks field and the type machinery built around it. CherryMessagePart, CherryUIMessage, Message, AssistantMessageStatus are the v2 vocabulary.

src/shared/data/api/schemas/agentSessions.ts (new)

Entity + DTO schemas for sessions. UpdateSessionSchema deliberately omits workspaceId to enforce insert-only binding.

src/shared/data/api/schemas/agentWorkspaces.ts (new)

Entity + DTO schemas for workspaces. Path validation matches the shared main-process normalizeWorkspacePath helper.

src/shared/data/api/schemas/agents.ts (slimmed)

AgentEntity derived from the new schema. The 126-LOC reduction comes from dropping per-session fields that moved to sessions.ts.

Multi-model & data shape changes

Three places where the data model now distinguishes models:

  1. Agent. Three FKs to userModel: model (default chat), planModel (planning), smallModel (helper calls). Migration maps v1's single model_id to all three (the renderer overrides plan / small as the user configures them).
  2. Message. modelId: UniqueModelId (providerId::modelId) is the v2 model identifier; v1's provider: string + model_id: string merge into this. Persistent chats already used UniqueModelId; agent messages now use it too.
  3. Multi-model assistant turn. siblings_group_id (already on the message tree) groups parallel assistant replies. The migrator preserves existing sibling groups; the stream-manager's persistent provider allocates new ones for fresh multi-model turns.

Invariants reviewers should check

  1. Cognitive config lives on the agent, not the session. A UpdateSessionDto adding model/mcps/allowedTools is wrong — those changes go through UpdateAgentDto.
  2. Workspace is insert-only on sessions. No code path should call sessionService.update(id, { workspaceId: ... }). The schema rejects this; reviewer should also catch any handler / hook that bypasses the schema.
  3. Workspace deletion cascades to sessions. agent_session.workspaceId is non-null and uses onDelete: 'cascade'. A system workspace row is owned one-to-one by its session and is deleted with that session; user workspace rows must not be deleted when deleting one bound session.
  4. blocks field is gone. Any newly added code that reads data.blocks or message.blocks is wrong. The migration is the only place legacy blocks ever appears, and it converts to parts.
  5. runtime_resume_token (opaque runtime resume token) is null until first run. Persistence writes it; reads must accept null.
  6. Tool IDs are ${serverName}__${toolName} (double underscore) in the allowedTools JSON arrays. mcps is string[] of server ids.
  7. agent_task is not the generic job stack. Review of agent tasks here should not touch JobManager / SchedulerService / job_schedule — those land via origin/v2 independently.

Validation

  • services/__tests__/AgentService.test.ts, AgentSessionService.test.ts, AgentWorkspaceService.test.ts, MessageService.test.ts, AgentChannelService.test.ts, AgentTaskService.test.ts.
  • migration/v2/migrators/__tests__/AgentsMigrator.test.ts, AgentsMigrator.transforms.test.ts, mappings/__tests__/AgentsDbMappings.test.ts, remapAgentPrefixIds.test.ts.
  • migration/v2/migrators/__tests__/ChatMigrator.test.ts for transformBlocksToParts.
  • src/shared/data/api/schemas/__tests__/agents.test.ts, agentWorkspaces.test.ts.
  • api/handlers/__tests__/agents.test.ts, temporaryChats.test.ts, temporaryChats.integration.test.ts.

Follow-ups (out of scope)

  • agents.db lives as a separate SQLite file pre-migration. After v2 GA the legacy file should be deleted; for now the migrator just reads it.
  • The _skills tables (agent_global_skill, agent_skill) currently mirror the v1 catalog 1:1. A future "skill registry" pass may re-shape both.
  • MessageService.ts is at 1163 LOC — splitting into tree / sibling / branch helpers is queued for a follow-up.
  • Wiring agent_task execution onto the generic JobManager (instead of an agent-local scheduler) — depends on the job-and-scheduler stack landing on this branch via merge and is not reviewed in this cluster.