本地数据与备份
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 行排查表,并如实记录两处代码与文案不一致。
这一页讲三件容易被混为一谈的事:数据到底存在本地哪里、升级出问题时靠什么恢复、以及**「账号同步」到底同步了什么**。 三者的共同点是:界面上的入口都很小,但背后的规则相当细,而且有几条与直觉相反。
先给五条最容易被写错的结论:
- 桌面端有两套完全不同的数据根,而且都带 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-agent | memory/(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 文件
Spark Work 的权威存储只有一个文件:{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. 升级前自动恢复点
每次有新迁移要执行时,Spark Work 会先给数据库做一份完整快照,再动 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_BACKUPS | 2 | 按 mtime 从新到旧保留 2 份(即「当前版本 + 上一版本」) |
DEFAULT_BACKUP_MAX_AGE_DAYS | 14 | 超过 14 天的整份回收,不管数量 |
STALE_TMP_DIRECTORY_MAX_AGE_MS | 24 小时 | 崩溃残留的.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):
log.error记录失败原因;- 弹出阻塞式错误框,标题「Spark Work 无法创建升级恢复点」,正文说明应用将退出且不会执行迁移;
throw抛出。
因为这一步发生在createDatabase()之前, 所以迁移一定不会被执行。抛出后由顶层兜住:关掉升级窗口、记录日志、 请求应用退出。注意这条路径不会尝试任何恢复——恢复点根本没建成。 失败时那个.tmp- 临时目录会被清掉。
5. 继承安装版数据(仅 dev 实例可见)
「设置 → 存储与备份 → 导入与恢复 → 继承安装版数据」是把安装版的数据库快照导进当前 dev 实例,用来在开发时用正式数据复现问题。
5.1 什么时候这个入口才出现
可用性判定有两条硬条件(ProductionDbInheritService.ts:87-98):
- 当前 userData 目录名以
-dev结尾; - 兄弟目录(去掉
-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_requests | 30 天 | TERMINAL_TURN_REQUEST_RETENTION_MS |
| 更新器缓存 | 最近 2 个版本 | MAX_CACHED_RELEASE_DIRS |
| 主进程阻塞 profile | 最多 8 份 | MAX_BLOCK_PROFILES |
| Canvas 时间戳快照 | 编辑中 10 份 / 退出后 2 份 | CANVAS_SNAPSHOT_KEEP/..._ON_EXIT |
没有统一的RETENTION常量,每处各自定义。 这意味着你在估算「这台机器上 Spark Work 会长期占多少盘」时, 不能用一个倍数套所有数据类型。
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):
| 字段 | 默认 |
|---|---|
enabled | false |
categories.customCommands | false |
categories.prompts | false |
categories.memory | false |
categories.assistants | false |
categories.workflows | false |
categories.appearance | false |
categories.promptLibrary | false |
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 请先登录 Spark Work 账号 |
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() 的完整步骤
- 取当前用户 id;未登录直接同步抛出。
- 并发闸门:同一账号的重复调用返回同一个 in-flight Promise;账号已切换则直接拒绝。
- 读偏好:未开启抛「请先开启账号同步」;一个类别都没选抛「请至少选择一个同步类别」。
- 校验安全端点(见上)。
- 生成
operationId(UUID)与时间戳。 - 并发采集所有选中类别。单类失败不打断其他类,失败项进入 failures。
- 采集后再查一次账号是否切换(换了就抛,避免把 A 账号数据写进 B)。
- 若一个类别都没采成功 → 走
finishCollectionFailure(): 写lastOperation.status = 'failed'、返回errorCodes: ['SYNC_LOCAL_COLLECT_FAILED'],不请求服务端、也不 ack。 - 组装请求体(
operationId、设备信息、各类别的 records 与 baseHashes)。 - 发出请求。这里有两个特判:
- 错误信息包含字符串
404→ 抛「当前服务端暂不支持账号同步,请升级服务端后重试」; - 错误信息含
SYNC_INVALID_CATEGORY、且本次选中了promptLibrary、且没有带冲突选择→ 剔除 promptLibrary 重试一次。 带冲突选择时不重试(否则会丢用户的选择语义)。
- 归一化并严格校验响应(类别重复/缺失、字段形态非法都判为「同步服务返回了无效响应」)。
- 再查一次账号是否切换。
- 按固定顺序逐类本地应用(这个顺序很关键,见 8.2)。
- 单类应用:合并 skipped 统计 → 消毒服务端记录 → 调适配器 apply。 apply 抛错会被 catch 成
SYNC_LOCAL_APPLY_FAILED并继续下一类。 - 该类有错误码 → 写「保留原 state +
pendingApply: true」并不推进 revision;无错 → 写入新的revision/baseHashes/tombstones。 - 汇总状态:有影响状态的错误码时,成功应用 0 类 →
failed, 否则 →partial;SYNC_CATEGORY_UNSUPPORTED被显式排除、只展示不降级。 - ack 上报结果(失败只记 warn)。
- 把
lastOperation写进偏好并返回结果。
8.2 本地应用顺序 ≠ 类别枚举顺序
这两个顺序不一样,写错就会理解错依赖关系:
| 来源 | 顺序 |
|---|---|
类别枚举ACCOUNT_SYNC_CATEGORIES | customCommands→prompts→memory→assistants→workflows→appearance→promptLibrary |
实际应用顺序applyOrder | workflows→prompts→assistants→customCommands→memory→appearance→promptLibrary |
这不是笔误:workflows必须排在assistants之前, 因为 Agent 定义里可能引用工作流 id,工作流先落地引用才不会悬空; 引用缺失时报 SYNC_WORKFLOW_DEPENDENCY_MISSING。
8.3 preview 与 execute 的真实差别
预览不是只读接口,它会做全量本地采集,只是不写任何状态:
| 维度 | execute | preview |
|---|---|---|
| 本地采集 | 会 | 也会 |
| 校验 enabled / 类别 | 会 | 会 |
写lastOperation | 会 | 不会 |
| 写类别 revision / tombstones | 会 | 不会 |
| ack 上报 | 会 | 不会 |
支持conflictChoices | 支持 | 不支持(从不发送) |
| 404 文案 | 「暂不支持账号同步」 | 「暂不支持冲突预览」 |
| promptLibrary 剔类重试 | 有 | 没有 |
9. 同步的内容边界:四重校验
「敏感配置不会上传」这句界面文案背后是一套具体的过滤。它是逐条全有全无的: 一条记录只要有一处不合规,整条被跳过,而不是把敏感字段裁掉再传。
9.1 第一重:顶层字段白名单
每个类别有一份允许的顶层字段集合,只要出现一个不在集合里的键,整条丢弃(错误码SYNC_FIELD_NOT_ALLOWLISTED)。 几个代表性类别:
| 类别 | 允许的顶层字段 |
|---|---|
customCommands | id``name``description``prompt script``scriptLanguage``enabled updatedAt |
workflows | id``scope``name``version description``status``tags``enabled graph``createdAt``updatedAt |
assistants | id``kind``name``description enabled``isDefault``prompt``permissionMode skillIds``ruleIds``workflowIds``memberIds leaderId``coordinationMode``discussionRounds``metadata``createdAt``updatedAt |
appearance | id``theme``emptyHeroTheme``primary``density``font``fontSize``uiZoom``codeLigature``windowCorners``backdropBlur``autoCollapseTools``inlineTokenCount syntaxHighlight``timestampFormat``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。
这条链路是闭合的,四步都能在代码里指出来:
- 渲染端
patchAppearance()写的是整个外观对象 ({...readAppearance(), ...patch}), 而默认值里就有inlineTokenCount: false; - 采集侧对「已定义」的字段全量拷贝,所以
inlineTokenCount会被带进 payload; - 类型检查能过(
inlineTokenCount: 'boolean'登记过); - 键名黑名单命中裸词
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))
它同时起两个作用:
- 不上传:这些 id 不会进上传的 records; 也不会生成删除墓碑(否则会把你本地那条敏感记录在云端删掉)。
- 不被覆盖:作为
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 作用域的普通规则拒绝覆盖也拒绝删除 |
assistants | Agent 与团队按 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 |
单条提示词text | 256 000 字符 |
单条title | 2 000 字符 |
id | 1–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 字符 |
历史分页pageSize | 1–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() 在未登录时返回默认值,其余方法都会抛「请先登录 Spark Work 账号」 |
| 点「立即同步」没反应 | 同账号的重复调用会复用进行中的 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 冲突、清空日志不清轮转文件)。 页面如实描述了现状,但没有改动产品源码——这两处是产品侧决定的事, 不是文档问题。