高级治理阅读约 26 分钟最后核对 2026-09-20

本地数据与备份

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-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 文件

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_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. 弹出阻塞式错误框,标题「Spark Work 无法创建升级恢复点」,正文说明应用将退出且不会执行迁移;
  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常量,每处各自定义。 这意味着你在估算「这台机器上 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):

字段默认
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 请先登录 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() 的完整步骤

  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 重试一次。 带冲突选择时不重试(否则会丢用户的选择语义)。
  1. 归一化并严格校验响应(类别重复/缺失、字段形态非法都判为「同步服务返回了无效响应」)。
  2. 再查一次账号是否切换。
  3. 按固定顺序逐类本地应用(这个顺序很关键,见 8.2)。
  4. 单类应用:合并 skipped 统计 → 消毒服务端记录 → 调适配器 apply。 apply 抛错会被 catch 成 SYNC_LOCAL_APPLY_FAILED并继续下一类。
  5. 该类有错误码 → 写「保留原 state +pendingApply: true」并不推进 revision;无错 → 写入新的revision/baseHashes/tombstones。
  6. 汇总状态:有影响状态的错误码时,成功应用 0 类 →failed, 否则 → partial;SYNC_CATEGORY_UNSUPPORTED被显式排除、只展示不降级。
  7. ack 上报结果(失败只记 warn)。
  8. 把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 enabled``isDefault``prompt``permissionMode skillIds``ruleIds``workflowIds``memberIds leaderId``coordinationMode``discussionRounds``metadata``createdAt``updatedAt
appearanceid``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。

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

  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() 在未登录时返回默认值,其余方法都会抛「请先登录 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 冲突、清空日志不清轮转文件)。 页面如实描述了现状,但没有改动产品源码——这两处是产品侧决定的事, 不是文档问题。