本页目录(51 节)

本地数据与备份

SparkWork 本地数据、备份与账号同步完整指南:先把数据分成三个根讲清——应用数据在 {userData}(开发实例走兄弟目录 @spark/desktop-dev,由 SPARK_DATA_PROFILE 控制)、跨工作区记忆与看板在 ~/.spark-agent、Spark CLI 另用 ~/.spark。再讲唯一权威存储 spark.db(WAL 与 pragma、101 个迁移与 schema_migrations)与升级安全网:迁移前自动恢复点走 SQLite 在线备份产出单文件快照,保留 2 份 / 14 天,自动回滚的唯一门槛是 createdThisStartup === true;同一个 backups/database 目录下还有继承安装版数据时裸拷的 pre-inherit-* 三件套,它不受任何清理约束,且整个 backups/ 不计入存储用量、也没有界面清理入口。最后讲账号同步:7 个类别默认全关、只在点「立即同步」时执行、走同一个 /desktop-sync/execute 端点(预览靠 mode 区分),四重校验(字段白名单、键名黑名单、类型表、值文本扫描)是整条跳过而非裁剪,配保护列表防止云端覆盖本地。含保留期对照、真实限额、本地应用顺序、25 行排查表,并如实记录两处代码与文案不一致。

阅读约 26 分钟最后核对 2026-09-19

这一页讲三件容易被混为一谈的事:数据到底存在本地哪里、升级出问题时靠什么恢复、以及「账号同步」到底同步了什么。 三者的共同点是:界面上的入口都很小,但背后的规则相当细,而且有几条与直觉相反。

先给五条最容易被写错的结论:

  • 桌面端有两套完全不同的数据根,而且都带 spark: 应用数据在 {userData}(macOS 上是~/Library/Application Support/@spark/desktop), 跨工作区的长期记忆与看板在 ~/.spark-agent, 而 Spark CLI 用的是第三个目录 ~/.spark。三者互不包含。
  • 开发实例默认不共用你的正式数据。非打包运行会把 userData 整体切到兄弟目录 @spark/desktop-dev;这是为了修一个真实事故 (三方共用同一目录导致数据库在线恢复 split-brain 丢数据)。
  • 「升级前自动恢复点」只在真有迁移时创建,而且复用旧快照时不自动回滚。 自动恢复的开关是 createdThisStartup === true——只有在本次启动新建的快照才用于恢复。
  • 备份目录里其实住着两种格式的东西:pre-migration-v*是 SQLite 在线备份产出的单文件快照;pre-inherit-* 是继承安装版数据时 裸拷的三件套。而清理逻辑只认前者,后者不会被回收。
  • 账号同步默认一个类别都不开,也不会自动跑。7 个类别默认全是false,总开关也默认关闭;开与不开、同步什么,都只在你点「立即同步」那一刻生效。

1. 本地数据存在哪:三个根与一张总表

先把根目录分清,否则后面的路径都会对不上。代码里出现的是这三个:

根真实路径里面装什么
{userData}macOS:~/Library/Application Support/@spark/desktop;
dev 运行:兄弟目录 @spark/desktop-dev
业务库 spark.db、项目工作目录、会话附件、Canvas 项目、 凭据 vault、日志、备份、各类运行时与能力包
~/.spark-agent~/.spark-agentmemory/(user / agent 作用域长期记忆)、board-tasks.json 与 board-attachments/
~/.spark~/.spark(可用 SPARK_HOME 覆盖)Spark CLI 的 config.toml、credentials.json、bin/、会话记录。与桌面端不共用

路径推导:getDatabasePath() 返回join(app.getPath('userData'), 'spark.db')(apps/desktop/src/main/db.ts:24-27);userData 本身在应用启动最早期 就被 applyDevUserData() 决定(apps/desktop/src/main/index.ts:65-72)。

1.1 逐类数据的落点、写入方与保留策略

下表是「谁写的、写到哪、会不会被自动清掉」的完整对照。保留策略一栏写无自动清理 的,意思是只能靠用户手动删,或者干脆没有入口(并已在最后一列注明)。

数据类型路径保留策略界面清理入口
业务数据库{userData}/spark.db(+ -wal / -shm)无自动清理「清除历史运行时快照」(会 VACUUM)
数据库迁移前恢复点{userData}/backups/database/pre-migration-v<版本>/最多 2 份、超 14 天回收、崩溃残留 24 小时后回收无(只读展示)
继承安装版数据的前置备份{userData}/backups/database/pre-inherit-*无回收(前缀不匹配清理规则)无
集中凭据 vault{userData}/credential-vault-v1.enc无 TTL无
登录态加密备份{userData}/cloud-auth-session.enc无 TTL无(退出登录会清)
文件日志app.getPath('logs'),macOS 为 ~/Library/Logs/@spark/desktop/轮转 5 MB × 5 份「清空文件日志」
主进程阻塞现场 profile<logs>/main-block-profiles/最多 8 份无
Chromium 缓存{userData}/Cache、Code Cache、GPUCache 等 7 个目录无自动清理「清空浏览器与渲染缓存」
项目工作目录{userData}/projects/无自动清理「清理孤儿项目目录」
会话附件(粘贴图片 / 文本){userData}/attachments/pasted-images/、pasted-texts/无自动清理(刻意不放临时目录)无
临时媒体(粘贴 / 预览副本)<系统临时目录>/spark-agent-pasted-images 等7 天,每 6 小时扫一次无(自动)
Canvas 项目目录默认 {userData}/canvas-projects/,根可在设置里改时间戳快照 10 份,退出画布后收紧到 2 份;latest.json 永不删无「清空全部画布」按钮
旧全局画布资源{userData}/.spark-artifacts/media/无 TTL「清理旧画布孤儿资源」
工具结果归档<工作区>/.spark-agent/tool-results/7 天 + 总量 512 MB,单件上限 64 MB无
看板任务与附件~/.spark-agent/board-tasks.json、board-attachments/无自动清理看板内逐条删除
长期记忆(user / agent)~/.spark-agent/memory/user/、memory/agent/无基于时间的清理记忆面板逐条删除
长期记忆(project)<工作区>/.spark-agent/memory/无;刻意不 gitignore,随仓库版本化无
子应用分享包覆盖前备份{userData}/sub-app-backups/*.sparkapp无任何回收(只增不减)无
电脑操作快照库{userData}/snapshot-vault/blobs/(AES-256-GCM 加密)每 6 小时扫;行级 TTL;孤儿宽限 24 小时无
更新器缓存{userData}/@sparkdesktop-updater/<版本>/最多 2 个版本目录,启动时回收无(手动删目录)
用量账本spark.db 内 usage_ledger无自动清理「清理旧记录」(固定 90 天)
会话事件 / 终态请求spark.db 内 agent_events / turn_requests终态请求 30 天;增量 deltas 按批次清无

1.2 两个数据目录:dev 与安装版的隔离

代码注释里记着这个隔离策略的由来:打包产物 asar 内的 package.json 只有name(@spark/desktop)、没有 productName, 于是 Electron 推导出的默认 userData 会让「pnpm dev」「签名安装包」 「本地未签名安装包」三方共用同一个目录,曾在版本切换时触发 数据库在线恢复 split-brain 丢数据。

现在的规则由 shouldUseDevUserData() 决定(apps/desktop/src/main/data-profile.ts:40-45):

条件结果
SPARK_DATA_PROFILE=production用生产目录(即使是 dev 运行)
SPARK_DATA_PROFILE=dev用隔离目录(即使是打包产物)
未设置,且 !app.isPackaged用隔离目录
未设置,且已打包用生产目录
  • 取值会做 trim().toLowerCase(),所以 " Production "也能识别;空串等同未设置。
  • production 的判断先于 dev 判断返回,所以SPARK_DATA_PROFILE=production 一定会盖过 dev 默认行为。
  • 隔离目录 = 当前 userData 的兄弟目录加 -dev 后缀 (@spark/desktop → @spark/desktop-dev)。
  • 这个 -dev 后缀不只是命名习惯,还是隐式 API: 「继承安装版数据」功能就是靠它推断「安装版目录」的,改了后缀会同时打断两处。

实用推论:如果你在 dev 里看不到自己的正式会话,这是设计如此,不是 bug。 反过来,SPARK_DATA_PROFILE=production 跑 dev 会直接写你的正式库—— 只在明确要检查生产数据时使用。

2. 业务数据库:一个 SQLite 文件

SparkWork 的权威存储只有一个文件:{userData}/spark.db。 会话、消息、规则、工作流、Agent、审计日志、用量账本全在里面。 设置页顶部那句说明就是这个意思:「所有会话、规则、工作流与审计日志默认存在本地 SQLite」。

2.1 打开时的连接参数

连接由 SparkDatabase 构造函数统一开启(packages/storage/src/database.ts:167-171), 也是全仓唯一可写的建库入口:

journal_mode = WAL synchronous = NORMAL foreign_keys = ON temp_store = MEMORY mmap_size = 268435456 // 256 MB
  • WAL 意味着磁盘上同时存在 spark.db-wal 与spark.db-shm。任何「复制一份数据库」的操作都必须把这三个文件当成一个整体看待, 否则会拿到不一致的快照。
  • close() 会在退出时合并 WAL(packages/storage/src/database.ts:240-246), 所以要手工拷贝数据库,先正常退出应用最稳。
  • 只读预检走的是另一条路径:inspectPendingMigrations() 用readonly: true, fileMustExist: true 打开,不加任何 pragma、不建表、不切 WAL(注释见packages/storage/src/database.ts:113-117)。

2.2 迁移:101 个 SQL 文件与 schema_migrations

  • 迁移文件放在 packages/storage/migrations/,命名形如001_initial_schema.sql,当前共 101 个(最新为 101_hook_compensator_sweep_indexes.sql); 打包后映射到 {resourcesPath}/migrations/。
  • 版本号提取用的是 /^(\d+)/——只要能开头解析出数字就算合法,001abc.sql 也会被接受(只是与报错文案里写的{number}_{name}.sql 并不严格一致)。
  • 文件按文件名字典序排序执行。当前全是三位零填充,字典序恰好等于数值序; 如果将来出现四位数文件名(如 1000_x.sql),执行顺序会错乱。
  • 已应用记录写在 schema_migrations 表 (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT), 每条迁移各自一个事务。
  • 版本号重复会在读取阶段就抛错,报错里会点名两个撞号文件。

另外有两处特殊分支值得知道:版本 18 / 49 在检测到「schema 已反映」时 可以只登记不执行;版本 48 走一个专用兼容 handler 来补生成列与索引 (packages/storage/src/database.ts:264-317)。

2.3 手工操作数据库前请注意

  • 不要用别的工具在应用运行时直接改 spark.db,WAL 状态下的并发写很容易踩坏。
  • 想直接看文件,用「存储与备份 → 数据目录 → 打开」,再正常退出应用后拷贝。
  • inspectPendingMigrations() 会因「库文件不存在 / migrations 目录读不到 / 文件名解析不出数字 / 版本撞号 / schema_migrations 查询失败」而抛错; 上层拿到抛错后会转入保守路径——先照样做备份,再继续升级流程。

3. 升级前自动恢复点

每次有新迁移要执行时,SparkWork 会先给数据库做一份完整快照,再动 schema。 这份快照就是「设置 → 存储与备份 → 升级前自动恢复点」说的事 (SettingsView.tsx:4519-4522)。

3.1 什么时候会创建、什么时候复用

创建的总条件是「库已存在 且 本次需要准备升级」 (apps/desktop/src/main/index.ts:1065-1067):

const requiresUpgradePreparation = databaseExistedBeforeInitialization && (migrationPreflightFailed || pendingMigrations.length > 0)

在 ensurePreMigrationBackup() 内部还有第二层判断:

  • 源库不存在 → 直接返回 null,不做任何事 (这是正常路径,不是错误)。
  • 该版本目录下已有合法 manifest.json → 直接复用, 返回的快照里 createdThisStartup = false。
  • 否则新建,返回 createdThisStartup = true。

关键点:备份目录名里带版本号(pre-migration-v<版本>), 所以「同一个版本只备份一次」是天然成立的——重复启动同一版本会命中复用分支。 另外复用分支也会先跑一次清理再返回,所以过期备份该回收照样回收。

容易误解的一点:复用判定只看 manifest 的字段是否合法(databasePath / appVersion / createdAt 是字符串、files 是数组),不会校验目录里的 spark.db 是否还在、内容是否匹配。 如果那个文件被人手工删了,这里仍会判定为「可以复用」。

3.2 快照是什么格式:在线备份,不是拷文件

pre-migration-v* 走的是 SQLite Online Backup API(better-sqlite3 的 db.backup()),只读打开源库, 把主库与 WAL 合并成一个目标文件。代码注释写得很直白:

// SQLite Online Backup 将主库与 WAL 合并成单个一致性快照,并能提供真实页数进度。 // 不再分别复制 db/-wal/-shm,避免三个文件落在不同时间点。

所以一个迁移前恢复点目录里只有两个文件:

文件说明
spark.db已合并 WAL 的单文件一致性快照(文件名取自源库 basename)
manifest.json含 directory / databasePath / appVersion /createdAt / files / createdThisStartup,权限 0600

落盘方式是「先写临时目录再整体 rename」:临时目录名形如<目标目录>.tmp-<pid>-<时间戳>, 只有 rename() 成功后目录才可见。所以你在目录里看到.tmp- 开头的残留,说明那次备份中途被打断了。

进度是真实的页数进度:每步复制 SQLITE_BACKUP_PAGES_PER_STEP = 2048 页 (按常见 4 KiB 页约 8 MiB)。百分比封顶 99, 100% 只由收尾那次显式调用报出;重复的同一百分比不会重复上报。 进度回调抛错会被吞掉并记 warn,不会影响备份产物。

3.3 保留策略:2 份 / 14 天 / 24 小时

常量值含义
DEFAULT_MAX_DATABASE_BACKUPS2按 mtime 从新到旧保留 2 份(即「当前版本 + 上一版本」)
DEFAULT_BACKUP_MAX_AGE_DAYS14超过 14 天的整份回收,不管数量
STALE_TMP_DIRECTORY_MAX_AGE_MS24 小时崩溃残留的 .tmp- 目录超期回收(更近的可能属于并发启动的实例)

清理只处理名字以 pre-migration-v 开头的目录。两个边界要注意:

  • maxBackups < 1 时整个清理直接返回(连 24 小时的.tmp- 回收也一起跳过),不是「全部删除」。
  • maxAgeDays < 1 被视为不限期(cutoff 取负无穷), 这是为了避免误传 0 时把刚建好的备份按超龄删掉。

清理时机有两处:升级路径里备份写完后同步跑一次(包在 try/catch 里,失败只吞掉), 以及启动流程里主窗口与托盘创建之后延迟 5 秒异步跑一次兜底 (apps/desktop/src/main/index.ts:1356-1370)。 两处都不会因为清理失败而阻塞启动。

3.4 备份进度显示在哪

备份进度不走主窗口的 IPC,而是推给一个独立的、无边框的 「数据库升级」窗口(apps/desktop/src/main/startup-guidance.ts), 通过 webContents.executeJavaScript 注入。

它的阶段枚举只有三个值:

type StartupGuidanceStage = 'backup' | 'migration' | 'launch'
  • 这个窗口只在需要准备升级时创建。普通启动既没有窗口,也没有进度推送。
  • 到了 migration 与 launch 阶段,UI 会把 backup 一步 显示为 100%,所以你在后面阶段看到的「备份已完成」是 UI 补齐的,不代表又备份了一次。
  • 窗口已关闭或销毁时,进度推送是静默 no-op。

4. 迁移失败:自动恢复与它的边界

这是全篇最值得逐字读的一段,因为「有恢复点」和「会自动恢复」不是同一件事。

4.1 自动恢复的唯一开关是 createdThisStartup

数据库初始化失败时,恢复逻辑长这样(apps/desktop/src/main/index.ts:1250-1253):

let restored = false if (databaseBackup?.createdThisStartup === true) { try { await restoreDatabaseBackup(databaseBackup) restored = true // ...

也就是说:只有本次启动新建的快照会被自动用于回滚。三种情况都不会恢复:

情况结果
快照是复用的(createdThisStartup === false)不恢复,弹「数据库无法安全初始化,应用将退出以避免继续写入或扩大损坏」
本次启动没进入升级准备(databaseBackup 为 null)不恢复,同上弹窗
备份阶段本身失败(快照压根没建成)不恢复;这条路径的弹窗是另一条(见 4.3)

代码里没有写这条门槛的理由注释。合理推断是:复用快照可能早于当前库状态, 回滚它会丢数据——但这是推断,不是代码明文。

4.2 恢复动作具体做了什么

for (const suffix of DATABASE_SUFFIXES) { await rm(`${snapshot.databasePath}${suffix}`, { force: true }) } for (const file of snapshot.files) { // 按文件名后缀决定写回成主库 / -wal / -shm await copyFile(join(snapshot.directory, file), `${snapshot.databasePath}${suffix}`) }
  • DATABASE_SUFFIXES = ['', '-wal', '-shm']。先删当前三件套,再写回快照。
  • 原库不做重命名保留,是直接删除。唯一留下的就是快照目录本身。
  • 恢复前的前提是 WAL 与文件锁已释放:createDatabase() 在迁移失败时会 先 db.close() 再抛错(packages/storage/src/database.ts:386-393)。
  • 快照目录不会被删,仍留在{userData}/backups/database/pre-migration-v<版本>/, 弹窗里会把该路径给你,让你保留并联系支持。
  • 写回逻辑里那段「按后缀判断」对现有产物其实是死代码: 写入方 files 永远只含主库名一个文件,解析不出 -wal / -shm。 只有手工改过 manifest 才可能走到。

4.3 备份失败时的后果链

备份失败(磁盘满、目录没权限等)会走这条链 (apps/desktop/src/main/index.ts:1092-1099):

  1. log.error 记录失败原因;
  2. 弹出阻塞式错误框,标题「SparkWork 无法创建升级恢复点」,正文说明应用将退出且不会执行迁移;
  3. throw 抛出。

因为这一步发生在 createDatabase() 之前, 所以迁移一定不会被执行。抛出后由顶层兜住:关掉升级窗口、记录日志、 请求应用退出。注意这条路径不会尝试任何恢复——恢复点根本没建成。 失败时那个 .tmp- 临时目录会被清掉。

5. 继承安装版数据(仅 dev 实例可见)

「设置 → 存储与备份 → 导入与恢复 → 继承安装版数据」是把安装版的数据库快照导进当前 dev 实例,用来在开发时用正式数据复现问题。

5.1 什么时候这个入口才出现

可用性判定有两条硬条件(ProductionDbInheritService.ts:87-98):

  1. 当前 userData 目录名以 -dev 结尾;
  2. 兄弟目录(去掉 -dev)里确实存在 spark.db。

不满足时任一条都会给出对应原因文案(「当前实例未运行在 dev 沙箱数据目录」/ 「未找到安装版数据目录中的 spark.db」)。也就是说:SPARK_DATA_PROFILE=production 下这个功能自动不可用, 因为那时 userData 没有 -dev 后缀。

5.2 两阶段流程:stage 与 apply

点「继承并重启」不是一步完成,而是分成两段,中间隔一次重启:

阶段做什么关键路径
stage用 SQLite 在线备份只读导出安装版数据库,写成一个暂存文件,并落一个待应用标记{userData}/spark.db.incoming、{userData}/inherit-db.pending
apply在建库之前执行:先给当前库整组备份,再删当前三件套, 把 incoming 原子 rename 成 spark.db,最后清标记{userData}/backups/database/pre-inherit-v<版本>-<时间戳>/
  • stage 阶段用的是在线备份(只读打开安装版库 + backup()), 所以安装版正在运行时也可以执行,数据不会被 dev 实例改动。
  • apply 阶段用的是裸 copyFile 三件套(存在才拷), 因为那个时机还没有数据库连接可用,只能用文件复制。
  • apply 阶段的备份目录内容是 spark.db / spark.db-wal /spark.db-shm 加上一份 manifest, manifest 字段是 databasePath / appVersion / stagedAt / sourcePath / createdAt——注意它没有 files 字段,与迁移前恢复点的 manifest 形状不同。
  • inherit-db.pending 不是「只允许继承一次」的开关:成功与失败都会删掉它。 这个功能设计上就是可重复执行的,用来同步安装版的最新数据。

5.3 失败语义:绝不阻断启动

apply 阶段的失败不会向上抛错,只做三件事:记 log.error、 清掉标记、返回 { applied: false },然后用现有库正常启动。 另外两个健壮性分支也会提前退出并清标记:标记 JSON 损坏、spark.db.incoming 不存在。

还有一个隐含效果:apply 发生在 databaseExistedBeforeInitialization判断之前,所以继承进来的生产库会被当作「既有库」参与迁移预检—— 如果它的 schema 比当前版本旧,就会再走一次备份 + 迁移。

6. 备份的清理规则与三个盲区

6.1 同一个目录住着两种格式

{userData}/backups/database/ 底下同时存在两类目录,格式与生命周期都不同:

前缀产生方内容会被回收吗
pre-migration-v<版本>升级前恢复点单文件 spark.db(WAL 已合并)+ manifest(含 files)会:2 份 / 14 天
pre-inherit-v<版本>-<时间戳>继承安装版数据时给旧库做的备份裸拷三件套 + manifest(无 files)不会:清理只匹配 pre-migration-v

所以「备份最多占 2 份」这个印象只对迁移前恢复点成立。 如果你反复用「继承并重启」,pre-inherit-* 目录会一直累积—— 全仓检索不到任何针对它的回收逻辑。

6.2 存储用量不统计备份

「存储用量」那块只统计 6 项:

合计 = spark.db(+ -wal + -shm) + Chromium 缓存 + projects/ + attachments/ + Canvas 项目根 + logs/

backups/ 不在其中,~/.spark-agent 也不在其中,snapshot-vault/ 与 sub-app-* 同样不在。 所以那个「合计」不是磁盘占用的完整口径,别拿它对账。 另外整个 backups/ 目录没有任何界面清理入口, 想腾空间只能手工删目录。

6.3 保留期一览:为什么口径不统一

数据保留期常量
临时媒体(粘贴 / 预览副本)7 天TEMP_MEDIA_RETENTION_MS
会话图片优化缓存7 天CACHE_MAX_AGE_MS
工具结果归档7 天 + 512 MB 总量TOOL_RESULT_RETENTION_MS / TOOL_RESULT_TOTAL_BYTES
Hooks 已解决事件7 天DEFAULT_RESOLVED_RETENTION_MS
媒体上传引用缓存6 天SPARK_TRANSFER_CACHE_TTL_MS
数据库迁移前恢复点14 天 / 2 份DEFAULT_BACKUP_MAX_AGE_DAYS
电脑操作执行证据24 小时EXECUTION_EVIDENCE_TTL_MS
终态 turn_requests30 天TERMINAL_TURN_REQUEST_RETENTION_MS
更新器缓存最近 2 个版本MAX_CACHED_RELEASE_DIRS
主进程阻塞 profile最多 8 份MAX_BLOCK_PROFILES
Canvas 时间戳快照编辑中 10 份 / 退出后 2 份CANVAS_SNAPSHOT_KEEP / ..._ON_EXIT

没有统一的 RETENTION 常量,每处各自定义。 这意味着你在估算「这台机器上 SparkWork 会长期占多少盘」时, 不能用一个倍数套所有数据类型。

Canvas 的 10 → 2 值得单独记住:离开画布(关窗或切项目)后, 可用的历史恢复点会从 10 份静默收紧到 2 份,而 latest.json 永远保留。 这在你需要回退到较早版本时会很关键。

7. 账号同步:定位与默认状态

入口在设置 →「通用」分组 →「账号同步」(SettingsView.tsx:353-382,搜索关键词包含「云同步」「手动同步」「备份」「跨设备」)。 它是把「安全的工作配置」在当前设备与已登录账号之间做手动双向同步。

7.1 默认全部关闭

偏好对象只有两个必填字段(packages/protocol/src/account-sync.ts:21-29):

interface AccountSyncPreferences { enabled: boolean categories: AccountSyncCategorySelection lastOperation?: { operationId: string; status: AccountSyncOperationStatus; finishedAt: string } }

默认值全部是关闭(AccountSyncService.ts:67-81):

字段默认
enabledfalse
categories.customCommandsfalse
categories.promptsfalse
categories.memoryfalse
categories.assistantsfalse
categories.workflowsfalse
categories.appearancefalse
categories.promptLibraryfalse
lastOperation不存在(首次读取返回里没有这个键)
  • 界面上那句「开启后仍不会后台运行;所有类别默认关闭,需要逐项选择」是准确的:enabled 只是手动开关,全仓没有同步调度器。
  • 读取时会做严格 === true 归一化, 所以字符串 "true"、数字 1 这类值都会落成 false。
  • 偏好按账号隔离存储:app_settings 的account-sync.preferences / user:<userId>。
  • updatePreferences 是浅合并 patch: 只覆盖显式传入的字段,categories 按 key 逐项合并,没传的类别保持原值。

7.2 未登录时各方法的行为并不一致

方法未登录时的行为
getPreferences()不抛错,返回 { authenticated: false, preferences: 默认偏好 }
updatePreferences()抛 VALIDATION_FAILED 请先登录 SparkWork 账号
execute()抛同一错误,且是同步抛出(不是 rejected promise)
preview()抛同一错误
listHistory()抛同一错误(async 方法,因此表现为 rejection)

跨过 IPC 之后渲染层统一看到的是错误对象,因为注册的 handler 都是async 箭头函数,同步抛出会转成 rejection。 所以「未登录能打开设置页、但点不动任何操作」是预期行为。

8. 同步怎么跑:一个 endpoint、两套模式

同步的目标端是同一个 HTTP endpoint,靠请求体里的 mode 区分 是「预览」还是「真同步」:

用途方法与路径说明
执行同步POST /api/v1/desktop-sync/execute不带 mode,即默认应用
冲突预览POST /api/v1/desktop-sync/execute同一路径,请求体带 mode: 'preview'
回执上报POST /api/v1/desktop-sync/operations/<id>/ack上报 status 与 errorCodes;失败只记 warn,不影响返回
历史记录GET /api/v1/desktop-sync/history分页参数 page / pageSize

基础地址由设置页可切换的服务器地址决定,客户端写相对路径,resolveUrl 会自动补 /api/v1 前缀。 超时是 30 秒(EduServerClient.ts:54), 业务层没有通用重试;唯一的自动重试是 HTTP 401 时用 refreshToken 续期后重放, 最多续期 2 次。

发起请求前有一道安全闸:

// base URL 既不是 https、也不是 loopback(localhost / 127.0.0.1 / ::1) // → 抛「账号同步仅允许 HTTPS 服务端或本机开发地址」

注意这道闸只校验配置里的 base URL,不校验实际拼出来的请求 URL; 而且它在 enabled 与类别校验之后执行, 所以「没开同步」的报错会先于「HTTPS 不合格」出现。

8.1 execute() 的完整步骤

  1. 取当前用户 id;未登录直接同步抛出。
  2. 并发闸门:同一账号的重复调用返回同一个 in-flight Promise;账号已切换则直接拒绝。
  3. 读偏好:未开启抛「请先开启账号同步」;一个类别都没选抛「请至少选择一个同步类别」。
  4. 校验安全端点(见上)。
  5. 生成 operationId(UUID)与时间戳。
  6. 并发采集所有选中类别。单类失败不打断其他类,失败项进入 failures。
  7. 采集后再查一次账号是否切换(换了就抛,避免把 A 账号数据写进 B)。
  8. 若一个类别都没采成功 → 走 finishCollectionFailure(): 写 lastOperation.status = 'failed'、返回errorCodes: ['SYNC_LOCAL_COLLECT_FAILED'],不请求服务端、也不 ack。
  9. 组装请求体(operationId、设备信息、各类别的 records 与 baseHashes)。
  10. 发出请求。这里有两个特判:
    • 错误信息包含字符串 404 → 抛「当前服务端暂不支持账号同步,请升级服务端后重试」;
    • 错误信息含 SYNC_INVALID_CATEGORY、且本次选中了promptLibrary、且没有带冲突选择→ 剔除 promptLibrary 重试一次。 带冲突选择时不重试(否则会丢用户的选择语义)。
  11. 归一化并严格校验响应(类别重复/缺失、字段形态非法都判为「同步服务返回了无效响应」)。
  12. 再查一次账号是否切换。
  13. 按固定顺序逐类本地应用(这个顺序很关键,见 8.2)。
  14. 单类应用:合并 skipped 统计 → 消毒服务端记录 → 调适配器 apply。 apply 抛错会被 catch 成 SYNC_LOCAL_APPLY_FAILED 并继续下一类。
  15. 该类有错误码 → 写「保留原 state + pendingApply: true」并不推进 revision;无错 → 写入新的revision / baseHashes / tombstones。
  16. 汇总状态:有影响状态的错误码时,成功应用 0 类 → failed, 否则 → partial;SYNC_CATEGORY_UNSUPPORTED 被显式排除、只展示不降级。
  17. ack 上报结果(失败只记 warn)。
  18. 把 lastOperation 写进偏好并返回结果。

8.2 本地应用顺序 ≠ 类别枚举顺序

这两个顺序不一样,写错就会理解错依赖关系:

来源顺序
类别枚举 ACCOUNT_SYNC_CATEGORIEScustomCommands → prompts → memory →assistants → workflows → appearance →promptLibrary
实际应用顺序 applyOrderworkflows → prompts → assistants →customCommands → memory → appearance →promptLibrary

这不是笔误:workflows 必须排在 assistants 之前, 因为 Agent 定义里可能引用工作流 id,工作流先落地引用才不会悬空; 引用缺失时报 SYNC_WORKFLOW_DEPENDENCY_MISSING。

8.3 preview 与 execute 的真实差别

预览不是只读接口,它会做全量本地采集,只是不写任何状态:

维度executepreview
本地采集会也会
校验 enabled / 类别会会
写 lastOperation会不会
写类别 revision / tombstones会不会
ack 上报会不会
支持 conflictChoices支持不支持(从不发送)
404 文案「暂不支持账号同步」「暂不支持冲突预览」
promptLibrary 剔类重试有没有

9. 同步的内容边界:四重校验

「敏感配置不会上传」这句界面文案背后是一套具体的过滤。它是逐条全有全无的: 一条记录只要有一处不合规,整条被跳过,而不是把敏感字段裁掉再传。

9.1 第一重:顶层字段白名单

每个类别有一份允许的顶层字段集合,只要出现一个不在集合里的键,整条丢弃(错误码 SYNC_FIELD_NOT_ALLOWLISTED)。 几个代表性类别:

类别允许的顶层字段
customCommandsid name description prompt script scriptLanguage enabled updatedAt
workflowsid scope name version description status tags enabled graph createdAt updatedAt
assistantsid kind name description enabledisDefault prompt permissionMode skillIdsruleIds workflowIds memberIds leaderIdcoordinationMode discussionRounds metadatacreatedAt updatedAt
appearanceid theme emptyHeroTheme primarydensity font fontSize uiZoomcodeLigature windowCorners backdropBlurautoCollapseTools inlineTokenCount syntaxHighlighttimestampFormat updatedAt

注意 assistants 的白名单里没有providerProfileId / modelId / agentAdapter /reasoningEffort / mcpServerIds / hookConfig。 这些是靠「不在白名单」被排除的,不是靠黑名单—— 两种机制的结果一样,但理解上要区分开。

9.2 第二重:键名黑名单(只匹配键,不匹配值)

完整正则是这一行:

/(?:api.?key|access.?token|refresh.?token|token|password|passwd|secret|credential|authorization|cookie|headers?|env(?:ironment)?|keystore|provider.?profile|model.?id|reasoning.?effort|agent.?adapter|mcp|hooks?)/i

它的匹配语义有几个必须知道的特征:

  • 只扫键名,不扫值。递归到任意嵌套深度、数组也遍历。
  • 裸词子串匹配:正则没有 ^...$ 锚, 任何位置命中都算——所以 mcpServerIds、inlineTokenCount、prefixEnv 都会命中。
  • 大小写不敏感(/i);api.?key 里那个 .没有转义,因此它其实是「任意字符」,apiXkey 也算命中。
  • 只检查对象的自有可枚举字符串键;Symbol 键与原型链上的键不检查。

下面这组对照能帮你看清「键名过滤」和「语义过滤」的差别:

输入结果原因
键 mcpServerIds被拦键名含裸词 mcp
值 toolSource: "mcp"放行值不参与键名匹配(键是 toolSource)
键 toolServerId放行不含任何词条
键 inlineTokenCount被拦键名含裸词 token——见 9.4,这一条有实际后果

9.3 第三、四重:字段类型与值文本扫描

黑名单之后还有两道:

  • 字段类型表:每个类别的每个字段都登记了期望类型 (string / nullable-string / boolean /number / string-array / object), 不匹配则整条丢弃(SYNC_FIELD_TYPE_INVALID)。
  • 值文本扫描:用另一组正则扫值里的敏感内容, 命中时把命中的模式名本身当作原因码返回。完整的原因码有:SECRET_PEM_PRIVATE_KEY、SECRET_BEARER_TOKEN、SECRET_JWT、SECRET_COMMON_API_KEY、SECRET_CREDENTIAL_URL、LOCAL_ABSOLUTE_PATH。
  • 豁免规则:值以 data:image/ 开头时跳过文本扫描 (避免 base64 撞上密钥/路径模式误报);其它 data: 类型照常扫。
  • assistants 的 metadata 还有一层专用子白名单:只允许 avatar 一个键,其它键一律判违规。 而且这个检查只对 assistants 生效, 其它类别走同一个函数时会直接返回「无问题」。

另外提醒一个实现细节:判定函数返回的是同一对象引用,没有深拷贝。 策略层做的是「校验 + 拒绝」,不是「投影 + 复制」。

9.4 一个实际后果:外观设置会整类被拦

把 9.1 与 9.2 放在一起看,会发现一处自相矛盾:appearance 的白名单收进了 inlineTokenCount, 而同文件的键名黑名单里有一个裸词 token—— 于是这个字段只要存在,就会让整条 appearance 记录被判SYNC_FORBIDDEN_FIELD。

这条链路是闭合的,四步都能在代码里指出来:

  1. 渲染端 patchAppearance() 写的是整个外观对象 ({...readAppearance(), ...patch}), 而默认值里就有 inlineTokenCount: false;
  2. 采集侧对「已定义」的字段全量拷贝,所以 inlineTokenCount会被带进 payload;
  3. 类型检查能过(inlineTokenCount: 'boolean' 登记过);
  4. 键名黑名单命中裸词 token → 整条 SYNC_FORBIDDEN_FIELD。

实测确认(直接加载真实模块调用策略函数):

不带 inlineTokenCount → { item: { ... } } 带上 inlineTokenCount → { skipped: { id: 'appearance', reasonCode: 'SYNC_FORBIDDEN_FIELD' } }

系统性检查也做了:把全部 52 个白名单字段名逐个拿黑名单正则去测,只有 inlineTokenCount 这一个命中。 也就是说这是一个孤立的口径冲突,不是大面积问题。

实际影响与现状:只要你动过一次外观设置,整个「外观」类别就会在每轮同步里 被静默跳过(界面只体现为「跳过 N」的数字,原因码不在渲染端的错误码翻译表里), 同时该条 id 会进入保护列表——结果是云端外观既不上传、也不会覆盖本地。 界面侧仍把这个字段列为可同步字段。 代码里没有任何注释说明这是有意设计,因此判定为缺陷而非策略。 这一条是本次核实中发现的,页面如实记录,未在源码侧做任何改动。

9.5 同类机制:绑定 MCP 的工作流被静默跳过

同样因为「裸词 + 递归扫键」,工作流也会被拦: 整张 graph 原样进 payload,图节点 schema 里合法存在mcpServerIds 键,于是命中裸词 mcp → 整条工作流SYNC_FORBIDDEN_FIELD。

graph 里含 mcpServerIds → { skipped: { id: 'wf1', reasonCode: 'SYNC_FORBIDDEN_FIELD' } } graph 里用 toolSource: 'mcp' → { item: { ... } } // 值不触发

这里要精确表述成「键名撞词的连带效果」,而不是「绑定 MCP 的工作流会被拦」—— 因为同一张图若用 toolSource: 'mcp' + toolServerId 表达, 反而能通过。设计文档确实要求「命中时跳过整条工作流、不做破坏性裁剪」, 所以「整条跳过」是刻意的,但把 MCP 绑定卷进去是词条过宽的副作用。

后果还有一条连锁:被跳过的 id 会进保护列表, 而且 seenIds 是在校验之前登记的, 所以它既不会被上传、也不会生成删除墓碑——云端旧副本保留、本地版本保留, 两边静默分叉,你只会看到一个「跳过 1」。

10. 冲突处理与保护列表

10.1 保护列表:防止云端覆盖本地的唯一机制

保护列表来自本次采集里被判为敏感的条目 id 集合, 它只存在于内存、每轮重算、不落库、不上传:

const protectedIds = new Set(collected.skippedItems.map((item) => item.id))

它同时起两个作用:

  1. 不上传:这些 id 不会进上传的 records; 也不会生成删除墓碑(否则会把你本地那条敏感记录在云端删掉)。
  2. 不被覆盖:作为 protectedIds 传给各适配器的 apply, 每个适配器开头都有一句 if (protectedIds.has(item.id)) continue。

推论:保护不是粘性的。如果你把提示词里的敏感内容删干净了, 下一轮它就不再被拒绝,会重新进入上传集合。

10.2 冲突:客户端不做裁决

  • 冲突判定的输入是哈希不是时间:请求体带baseRevision 与各类别的 baseHashes, 服务端返回合并后的 canonical records 与新的 revision。
  • 适配器层完全不做时间戳比对,拿到什么就写什么 (唯一的过滤是保护列表)。所以「未选择的冲突按修改时间自动处理」这条规则 是服务端行为,客户端代码里看不到实现—— 本仓库只有调用方,服务端在另一个仓库。
  • 手工裁决通过 conflictChoices 回传, 键格式 <category>/<itemId>,值为 'local' 或 'cloud'。

10.3 各类别的合并细节

类别合并行为
customCommands按 id 覆盖整个对象(保护列表防止把本地敏感命令删掉)
prompts系统规则只同步 enabled 开关(按指纹匹配本地规则,匹配不到报SYNC_SYSTEM_RULE_NOT_FOUND);运行时提示词只写enabled 与 content;本地是 system 作用域的普通规则拒绝覆盖也拒绝删除
assistantsAgent 与团队按 id upsert;builtIn 条目拒绝删除;metadata 只替换 avatar 键、保留本机其它键; 团队 leader 找不到就整条跳过,成员缺失只报码不阻断;maxDepth 夹在 1–3、discussionRounds 夹在 1–20
memory按 id upsert 并重写 markdown 文件;hit_count 恒置 0,lastHitAt / sourceSessionId / links 恒置空 ——本机命中统计会在应用云端记忆时丢失
workflows按 id upsert;删除时若仍被本机引用(WorkflowReferenceGuardError) 只 console.warn 并跳过
appearance字段级合并:只覆盖 payload 里出现过的字段,保留本机其它字段
promptLibrary按 id 合并,usageCount 保留本机值, 云端分类并回本地分类列表

两个时间戳的坑:

  • 回写云端时间戳的类别:rules / agents /agent_teams / workflows / memory / appearance。
  • 一律写当前时间的类别:customCommands 与promptLibrary。也就是说这两类的本地updated_at 会因为「应用了云端数据」而变新。
  • 适配器内没有事务包裹,多次仓储调用逐条提交, 中途失败会留下部分应用的状态(错误码汇总在 errorCodes)。

11. 限额、并发与重试

11.1 真实限额

项上限
提示词库同步条目数2 000
提示词库总字符预算60 000 000
单条提示词 text256 000 字符
单条 title2 000 字符
id1–256 字符
tags最多 100 个,每项 ≤ 512
coverUrl≤ 12 000 000 字符
每类别服务端 records应用前 slice(0, 2000),超出会追加 SYNC_SERVER_ITEM_LIMIT_EXCEEDED
每类别服务端 skippedItems≤ 2 000,超出判为无效响应
预览单类别冲突明细≤ 2 000,超出判为无效响应
errorCodes / ackErrorCodes≤ 64 项,每项截断 128 字符
历史分页 pageSize1–100,默认 20
记忆采集三种作用域各取 2000,整体再截 2000

封面压缩的独立参数:源文件 ≤ 8 MB、输出最长边 512 px、 base64 结果 ≤ 240 KB,质量按 80 → 60 → 40 逐级降。 全都超限就返回 null,此时封面置空、条目文字照常同步。 远程 http(s) 封面 URL 原样保留、不下载—— 所以没有独立的文件上传步骤,压缩后的 dataUrl 直接写进 coverUrl 随 JSON 走。

11.2 并发:重复点击返回同一个 Promise

  • 同账号重复调用 execute():不报错、不排队, 而是返回同一个 in-flight Promise。
  • 账号已切换:直接拒绝,提示「账号已切换,请等待当前同步结束后重试」。
  • 闸门在 finally 中清除,并用 promise 身份比对避免误清新请求。
  • preview() 有独立的第二把闸门,与 execute 互不影响, 所以预览与同步可以并发(注释写明「预览为只读操作,可随时发起」)。
  • 这些保护是内存态,进程重启即失效。 渲染层另有一层按钮禁用作为兜底。

11.3 没有取消,重试基本靠手点

  • 没有取消能力:AccountSync 目录下没有任何 cancel / abort / 中断 IPC。
  • 唯一的自动重试是 8.1 里那条「剔除 promptLibrary 后重试一次」。
  • 「重试」的实际机制是:失败的类别不推进 revision, 所以你再点一次「立即同步」时,它会用同样的 base 重新做三方合并—— 这等价于一次手动重试。
  • 代码里写的 pendingApply / lastErrorCodes是只写状态:写了、读回了, 但没有任何分支消费它(没有自动重试,界面也不读)。 注释里「保留待应用状态」的说法在服务层并未兑现。

11.4 设备标识

设备标识是安装级的:一个随机 UUID 存在设置里, 标签形如 macOS #a1b2(取 uuid 后 4 位)。 它跨账号共享——同一台机器换账号登录,同步记录里显示的还是同一个设备标签。

12. 界面操作:三个动作

账号同步页上你实际只会做三件事,对应界面上的真实文案:

动作按钮文案你会看到什么
开启并选类别「启用账号同步」开关 + 每类一个开关未开总开关时,各分类开关是禁用的
执行同步「立即同步」左侧显示「已选择 N 类内容」或「尚未选择同步内容」; 完成后走 Toast:「账号同步完成」/「账号同步部分完成,请查看结果」/ 「账号同步失败,请查看错误信息」
处理冲突「预览并处理冲突」打开冲突面板,卡片标题「冲突预览 · N 项待处理」, 有「全部保留本机」「全部保留云端」「清除选择」批量按钮, 逐条可单选「保留本机」/「保留云端」,底部「应用选择并同步」

结果与记录区:

  • 「本次结果」显示「上传 N / 下载 N / 冲突 N / 跳过 N」, 最多显示 4 条中文错误提示。
  • 「同步记录」的说明是「服务端仅保存状态、数量和错误码,不保存历史正文」, 每行格式是「设备标签 · 类别中文名」加升降箭头。 记录不落本地,每次都是 GET 服务端。
  • 记录分页固定 20 条;状态标签映射为success → 成功、partial → 部分成功、failed → 失败,另有 ackStatus === 'pending' 显示为「本机待确认」。
  • 错误码在服务端侧原文保存,只在渲染层翻译成中文;SYNC_FORBIDDEN_FIELD 不在翻译表里,会落到兜底文案。

13. 「存储与备份」页能做的事

把这一页的入口一次列全,方便你对照寻找:

分区项说明
路径数据目录 + 「打开」直接打开 {userData}
当前工作区 + 「选择」/「关闭」Agent 文件工具的根目录
Canvas 项目根目录 + 「选择」新建画布项目的默认保存位置
存储用量 + 「刷新」6 项统计(见 6.2 的口径提醒)
导入与恢复升级前自动恢复点只读说明,没有按钮
导入对话历史 + 「导入」检测并导入宿主机 Claude Code / Codex 对话历史
继承安装版数据 + 「继承并重启」仅 dev 实例可见(见第 5 节)
清理清空浏览器与渲染缓存 + 「清空」不动 Cookies / Local Storage / Preferences,不会掉登录
清理孤儿项目目录 + 「清空」删除 projects/ 下不再被引用的目录,同时清浏览器缓存
清空文件日志 + 「清空」见 13.1 的注意事项
清除历史运行时快照 + 「清除」清大字段并 VACUUM 压缩数据库
迁移旧画布资源到项目目录 + 「迁移」把旧全局 media 目录的资源复制进项目文件夹并重写引用
清理旧画布孤儿资源 + 「清理」删除不再被任何快照引用的文件
(另一页)用量统计 → 清理旧记录固定删除 90 天以前的本地用量明细

13.1 「清空文件日志」清了什么、没清什么

界面文案写的是「清空 main.log 及轮转文件内容」, 但 clearLogFile() 的实现只有一句:

fs.writeFileSync(fileState.currentPath, '')

也就是只把当前那个日志文件截断为 0 字节,main.1.log … main.4.log 这些轮转文件原封不动。 同理日志查看器的 log:read 也只读当前文件, 所以它能看到的历史不超过一个 5 MB 段。

想真正清空:用「日志查看器 → 在文件夹中显示」打开目录后手工删轮转文件, 或者知道「清空」按钮的实际语义就是这样,别指望它腾出 5 × 5 MB。

14. 排查表

现象原因与处理
dev 里看不到正式环境的数据设计如此。dev 默认用 @spark/desktop-dev;需要正式数据就用「继承安装版数据」,或临时 SPARK_DATA_PROFILE=production
「继承安装版数据」这一项不显示两条硬条件之一不满足:当前目录不带 -dev 后缀,或兄弟目录里没有 spark.db。开启 SPARK_DATA_PROFILE=production 也会让它消失
备份目录里有 .tmp- 残留上次备份中途被打断。超过 24 小时的会被自动回收;不足 24 小时的会被保留(可能是并发实例在写)
backups/database 越占越大pre-inherit-* 前缀不在清理规则里,只会累积。迁移前恢复点才有 2 份 / 14 天的上限
迁移失败后没有自动回滚只有本次启动新建的快照会自动回滚(createdThisStartup === true)。复用旧快照时只弹提示不恢复。手工恢复:退出应用后把快照目录里的 spark.db 拷回 {userData}/spark.db,并删掉 -wal / -shm
启动时提示「无法创建升级恢复点」并退出备份阶段失败(磁盘空间 / 目录权限)。应用会主动退出且不执行迁移,检查磁盘与目录权限后重试
存储用量「合计」对不上磁盘占用合计只含 6 项,不含 backups/、~/.spark-agent、snapshot-vault/、sub-app-*
「清空文件日志」后目录还是很大它只截断当前日志文件,不动轮转文件(见 13.1)
账号同步页什么都点不动未登录。只有 getPreferences() 在未登录时返回默认值,其余方法都会抛「请先登录 SparkWork 账号」
点「立即同步」没反应同账号的重复调用会复用进行中的 Promise,所以第二次点击本来就不会新起一轮。看按钮是否处于 loading
提示「账号已切换,请等待当前同步结束后重试」同步过程中登录账号变了。等当前这轮结束,在新账号下重新发起
提示「请先开启账号同步」enabled 默认 false。要开总开关再选类别
提示「请至少选择一个同步类别」7 个类别默认全关,至少要勾一个
提示「账号同步仅允许 HTTPS 服务端或本机开发地址」配置的服务器地址既不是 https 也不是 loopback。这条校验只看配置的 base URL
提示「当前服务端暂不支持账号同步,请升级服务端后重试」服务端返回的错误信息里包含 404。判定方式是字符串包含,不是状态码
某类总显示「跳过 N」但看不到原因按条目记录的原因码不会展示,界面只有计数。最常见是键名撞黑名单(见 9.4 / 9.5)与值里含绝对路径
外观设置永远同步不过去见 9.4:白名单里的 inlineTokenCount 与黑名单的裸词 token 冲突,整类被判 SYNC_FORBIDDEN_FIELD
绑了 MCP 的工作流同步不过去图里的 mcpServerIds 撞裸词 mcp,整条工作流被跳过并进保护列表(见 9.5)
云端改了记忆,本地命中次数没了应用云端记忆时 hit_count 恒置 0、lastHitAt 等置空。这是当前实现
云端删了工作流,本地还在若该工作流仍被本机引用,删除会被 WorkflowReferenceGuardError 拦下并跳过(只记 console warn)
同步后本地 updated_at 变新了customCommands 与 promptLibrary 不回写云端时间戳,一律写当前时间(见 10.3)
同步没有取消按钮当前实现没有取消能力,只能等它跑完或超时(单请求 30 秒)
申请了同步但似乎没自动重试没有通用重试。唯一自动重试是「剔除 promptLibrary 重试一次」;其余靠再点一次「立即同步」,因为失败类别不推进 revision
提示「同步服务返回了无效响应」响应未通过严格校验:类别重复或缺失、字段类型不对、条数超限等
Canvas 历史恢复点突然只剩 2 个离开画布后会从 10 份收紧到 2 份(CANVAS_SNAPSHOT_KEEP_ON_EXIT),latest.json 不受影响
想手工备份整个应用数据退出应用后再拷贝 {userData};只拷 spark.db 会漏掉 WAL 里的最新写入

15. 事实边界

以下几项在本次代码核实中没有找到证据,页面按「未找到证据」处理,没有写成结论:

  • 服务端实现:本仓库只有 /desktop-sync/* 的调用方, 服务端代码在另一个仓库(edu-server)。 因此「未选择的冲突按修改时间自动处理」这条规则无法在本仓库验证, 只能说客户端不做时间戳比对、把它交给服务端。
  • 自动 / 定时同步:全仓没有同步调度器,enabled 只是手动开关。界面文案也明说「不会自动同步」。
  • 整个 execute 请求体的字节级上限:只找到提示词库的 2000 条与 6000 万字符预算,没有整包上限。
  • 备份失败 / 恢复失败的上报:只写本地日志, 没有找到上报后端或额外审计文件的证据。
  • ProductionDbInheritService 没有对应测试文件: 它的行为只有实现与注释可依据,没有测试佐证。
  • 磁盘上的两个孤儿文件:~/.spark-agent/artifacts/与 ~/.spark-agent/board-tasks.json.bak 在磁盘上存在, 但代码里找不到写入方。属于历史残留,不是当前功能。

另外,本次核实中发现了两处「代码与界面文案不一致」 (inlineTokenCount 冲突、清空日志不清轮转文件)。 页面如实描述了现状,但没有改动产品源码——这两处是产品侧决定的事, 不是文档问题。

速查表

生产 userData(macOS)
~/Library/Application Support/@spark/desktop
开发 userData
兄弟目录 @spark/desktop-dev(-dev 后缀)
数据目录开关
SPARK_DATA_PROFILE=dev | production
CLI 数据目录
~/.spark(可用 SPARK_HOME 覆盖)
跨工作区数据目录
~/.spark-agent
业务数据库
{userData}/spark.db(+ -wal / -shm)
数据库 pragma
WAL / synchronous=NORMAL / foreign_keys=ON / mmap_size=256MB
迁移文件数
101 个(packages/storage/migrations/)
迁移记录表
schema_migrations
迁移前恢复点目录
{userData}/backups/database/pre-migration-v<版本>/
继承备份目录
{userData}/backups/database/pre-inherit-v<版本>-<时间戳>/
恢复点保留
2 份 / 14 天 / .tmp- 残留 24 小时
备份进度粒度
2048 页/步(约 8 MiB),百分比封顶 99
自动回滚门槛
createdThisStartup === true
文件日志轮转
5 MB × 5 份
账号同步入口
设置 → 通用 → 账号同步
同步类别数
7 个,默认全部关闭
同步 endpoint
POST /api/v1/desktop-sync/execute(mode=preview 为预览)
同步超时
30 秒;401 续期最多 2 次,无通用重试
同步本地应用顺序
workflows → prompts → assistants → customCommands → memory → appearance → promptLibrary
提示词库同步上限
2000 条 / 6000 万字符
封面压缩
源 ≤8MB,输出最长边 512px、base64 ≤240KB
冲突选择键格式
<category>/<itemId> → local | cloud
同步历史分页
1–100,默认 20
临时媒体保留
7 天,每 6 小时扫描
工具结果归档保留
7 天 + 512MB 总量
Canvas 快照保留
编辑中 10 份 / 退出后 2 份
存储用量统计口径
6 项(不含 backups/ 与 ~/.spark-agent)

常见问题

展开 8 条常见问题
  • SparkWork 的数据到底存在哪里?

    应用数据在 {userData}(macOS 上是 ~/Library/Application Support/@spark/desktop),权威存储是其中的 spark.db;跨工作区的长期记忆与看板在 ~/.spark-agent;Spark CLI 另用 ~/.spark。三者互不包含,也没有自动合并。开发实例默认使用兄弟目录 @spark/desktop-dev。

  • 开发时怎么看到我正式环境的数据?

    先确认你跑的是 dev 实例(userData 目录以 -dev 结尾),然后在「设置 → 存储与备份 → 导入与恢复」里点「继承安装版数据」。它分两段执行:先只读导出安装版数据库,重启后再应用,并把当前开发库整组备份到 backups/database/pre-inherit-* 下。这个过程安装版数据不会被改动,也可以重复执行。

  • 升级出问题会丢数据吗?

    每个新版本首次执行数据库迁移前会自动做一份完整快照。迁移失败时,只有本次启动新建的快照会被自动用于回滚(createdThisStartup === true),复用的旧快照不会。回滚是先删当前数据库三件套再写回快照,原库不做重命名保留;快照目录本身不会被删,弹窗里会把路径给你。

  • 为什么备份目录越来越大,界面又看不到?

    backups/database 下有前缀不同的两类目录。pre-migration-v* 是迁移前恢复点,受「2 份 / 14 天」上限保护;pre-inherit-* 是继承安装版数据时裸拷的三件套,清理逻辑只匹配前一个前缀,所以它不会被回收。而且「存储用量」的合计里不包含 backups/ 目录,也没有任何界面按钮能清它,只能手工删。

  • 账号同步会上传我的 API Key 吗?

    不会。同步对每条记录做四重校验:顶层字段白名单、键名黑名单(含 apiKey / token / secret / credential / env / mcp 等词条)、字段类型表、值文本扫描(PEM 私钥、Bearer、JWT、常见 API Key 形态、含凭据的 URL、本机绝对路径)。而且是「整条跳过」而非裁剪字段,同时被跳过的 id 会进入保护列表,云端也不会覆盖本地那一份。

  • 为什么我的外观设置同步不过去?

    这是一个口径冲突:appearance 类别的白名单里收进了 inlineTokenCount,而同一份代码的键名黑名单里有一个裸词 token。裸词是子串匹配,所以该字段一旦存在就会让整条 appearance 记录被判为 SYNC_FORBIDDEN_FIELD。把全部 52 个白名单字段名逐个测过,只有这一个命中。界面只显示「跳过 N」,原因码不在错误码翻译表里,所以看起来是静默失败。

  • 同步能取消或自动重试吗?

    都不能。当前实现没有取消能力,也没有通用重试;唯一的自动重试是「服务端不支持 promptLibrary 时剔除该类重试一次」。重试的实际方式是再点一次「立即同步」——因为失败的类别不会推进 revision,下一次会拿同样的 base 重新做三方合并。另外同一账号重复点击不会新起一轮,而是返回进行中的那个 Promise。

  • 「清空文件日志」为什么没腾出多少空间?

    界面文案写的是「清空 main.log 及轮转文件内容」,但实现只把当前日志文件截断为 0 字节,main.1.log 到 main.4.log 这些轮转文件不会被删。日志轮转参数是 5 MB × 5 份,所以真正占用是那 4 个轮转文件。要彻底清空需用「在文件夹中显示」打开目录后手工删除。