WorkBuddy 空间合并
> 一句话:把 WorkBuddy 侧边栏里散落的多个「空间」并成一个,任务不丢、历史可开。
先讲清机制(否则一定会做错)
WorkBuddy 客户端侧边栏的「空间」不是文件夹,复制文件、改 workspace-display-names.json、
新建 .workbuddy 目录标记——全都不管用。实测验证过的机制:
- 侧边栏空间列表 =
~/.workbuddy/workbuddy.db(SQLite)的sessions表按cwd分组推导。
某路径下存在「保留的会话」时才显示为空间。 - 「保留的会话」判定(源码
isKeptWorkspaceConversation):有 cwd、非 playground、无 projectId、
非后台自动化生成。 ~/.workbuddy/workspace-display-names.json只是「路径 → 显示名」映射缓存,不决定是否显示。workspaces表在本机通常为空,也不决定显示。- 合并空间 = 改会话的
cwd;源路径会话清零后,原空间自动从侧边栏消失。
先纠正一个常见误解:「移动到空间」到底做了什么
WorkBuddy 里不存在"把一个任务放进某个已有空间"的操作。实测确认:
- 每个任务在创建时就拿到自己的 cwd 文件夹(
~/WorkBuddy/<创建时间戳>/)。 - 界面上的「移动到空间」实际只是把
is_playground由1改成0,cwd 完全不动。 - 于是那个任务自己的文件夹被"升格"显示成一个新空间——所以每次"移动到空间"都会多出一个空间,
而候选列表里不会出现"选择已有空间"。
结果就是:「空间太多想合并」根本不是官方支持的场景,用户只能靠任务不断累积出新空间。
这正是本 Skill 存在的理由。
⚠️ 手机端 / 网页端为什么不跟着变(产品级限制)
一句话结论:手机 App / 网页的空间列表按「云端存的 cwd」分组;而端云同步层的常规同步
从不同步 cwd 的变化。所以桌面端事后改本地 SQLite,手机端默认看不到。
但——有一条官方可达的通道可以补推 cwd,且已验证成功(见下一节)。证据(WorkBuddy 5.5.x,源码 + 运行日志双证):
- 空间实体怎么来的(asar
mappers.ts→toSpace()):{ type, cwd, name, autoGenerated },
其中autoGenerated = isPlayground ?? isAutoGeneratedWorkspace(cwd),
而isAutoGeneratedWorkspace(cwd)就是「目录名匹配^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}$」。 - 桌面侧边栏读的是本地会话(
localConversations.list({ transport:"local" }),
过滤space.type === "local" && !!space.cwd && space.autoGenerated !== true)
→ 所以改本地 SQLite 立刻生效。 - 端云同步(
extensions/edge-sync/server/index.cjs→trySyncSpace()):
它会比较prev.cwd !== cwd,但构造 patch 时只放isPlayground,从不放cwd:
js if (!prev || prev.isPlayground !== isPlayground) patch.isPlayground = isPlayground ? 1 : 0; // cwd 变化被静默吞掉:只写进 lastSyncedSpace 就 return
实测日志佐证:某日 28 次EB_SYNC_SPACE_PUSH,patch 全是{"isPlayground":0|1},cwd 一次没推过。 - cwd 上云只有一次机会:会话创建时(
syncCreateConversation→ CREATE,body 带workDir: cwd)。 - 官方确实存在能推 cwd 的接口
wb.conversations.move(cid, cwd)(patch{cwd, isPlayground:false}上云),
但实测日志里从未出现过move操作记录——用户点「移动到空间」时它没被调用,cwd 没上云。 - 相互方向也没有回写:asar 里没有「云端 cwd 回写本地」的代码 → 本地合体不会被自动撤销。
可自测的推论:只有「在目标空间路径下新建的任务」才会让手机端看到那个空间(新会话的
cwd 在创建时就上传了);已存在的老任务无论怎么搬,手机端都停在原处。
✅ 让手机端 / 网页端也同步(已验证可行的官方通道)
原理:edge-sync 扩展在登录时会跑一次 legacy 迁移,用来把"还没上云"的本地会话补传上去。
它判断"是否已上云"的依据,就是本地映射库:
~/.workbuddy/edge-sync-mapping-v4.db → 表 edge_sync_mapping(session_id, conversation_id, msg_channel)
migratedSessions 初始化自这张表。所以:把某条会话的映射行摘掉 → 它被判定为"未迁移" →
登录时自动重新上传,载荷里带着 workDir(就是会话当前的 cwd):
POST /console/as/conversation-sync/migrations/legacy
{ migrationId, deviceId, schemaVersion: 0, forceUpdate: true,
sessions: [ { sessionId, workDir: cwd, isPlayground, conversationTitle, ... } ] }
关键在 forceUpdate: true(源码注释亦提到服务端 migrationConversationForceUpdateCols):
对已存在的会话是就地更新,不是新建。
实测验证通过的行为(edge-sync.log):
[MIGRATE_DECISION] total=48 alreadyMigrated=47 unmigrated=1 skippedByPid=0
[MIGRATE_WILL_DO] sids=[02c32c8b(manual)]
[MIGRATE_BATCH_RESULT] detail=[02c32c8b:updated] ← 服务端返回 update,不是 import
[MIGRATE_MAPPING_SET] conversationId 与之前完全相同 ← 没有新建
EB_SYNC_CREATE_START = 0 ← 零新建
手机端随即把该任务归入了目标空间。不会产生重复会话。
操作步骤
- 备份映射库(必做):
cp -p ~/.workbuddy/edge-sync-mapping-v4.db ~/.workbuddy/backups/edge-sync-mapping-v4.db.bak-<时间戳> - 先验证一条:只删 1 条会话的映射行,让用户重启,确认日志出现
:<sid>:updated且手机端有变化。 - 再批量:删除其余会话的映射行(
DELETE FROM edge_sync_mapping WHERE session_id=?,用事务)。 - 把被删行的
(session_id, conversation_id, msg_channel)存成回滚档案(JSON),便于一键还原。 - 让用户彻底退出客户端再打开;迁移只在登录时触发。
- 用日志三行判定结果:
MIGRATE_WILL_DO(是否触发)、MIGRATE_BATCH_RESULT(updated/skipped/failed)、
MIGRATE_ALL: imported=? skipped=? failed=?。
注意事项
- 前提:登录时
LIST_SUPPLEMENT不应把删掉的行补回来(实测fetched=0 supplemented=0,即云端列表为空)。
若该接口开始返回会话,会先行补齐映射、导致迁移不触发——此时需另想办法。 - 手机端显示的空间名 = 目录名。云端没有"工作空间命名"接口
(asar 里workspaceName只用于 ClawService 注册桌面设备),本地workspace-display-names.json
也只影响桌面端。所以目标文件夹若叫2026-09-18-20-34-07,手机端就会显示成这串时间戳。
想要好看的名字,就得在推送前把目标文件夹重命名(注意:重命名会改变cwd,需同步改
sessions.cwd、projects/<sanitize(path)>目录名和显示名映射,且会打断该目录下会话历史里的绝对路径引用)。 - 回滚:把备份的映射库
cp回去即可(映射行只影响"是否重新迁移",不含业务数据)。 - 排障时可用环境变量开详细日志:
EDGE_SYNC_LOG_VERBOSE=1;EDGE_SYNC_LOG=0可整体关闭。 - 日志位置:
~/.workbuddy/logs/<YYYY-MM-DD>/edge-sync.log(非详细模式已含上述判定所需的几行)。
动手前先问清
只在桌面端用,还是要多端一致?
- 只在桌面端 → 改本地 SQLite 即可(仍须遵守"不要删源文件夹")。
- 要多端一致 → 本地合体后,再按上面这节补一次 cwd 推送即可,两端都能一致。
前提确认(先问,不要猜)
- 哪几个源空间 → 合并到哪个目标空间(目标可以是新建文件夹,也可以是已有空间路径)。
- 迁移方式:会话整体搬迁(原空间消失)还是只想多个任务并到一处。
- 明确告知:这是改动应用内部数据库,会先备份,可回退。
执行步骤
1. 只读摸底
DB=~/.workbuddy/workbuddy.db
sqlite3 -readonly "$DB" ".schema sessions"
sqlite3 -readonly "$DB" "SELECT cwd, COUNT(*) n FROM sessions WHERE deleted_at IS NULL GROUP BY cwd ORDER BY n DESC;"
用 workspace-display-names.json 把路径映射成用户能认出的显示名,让用户点选。
2. 备份(必做)
cp -p ~/.workbuddy/workbuddy.db ~/.workbuddy/workbuddy.db.bak-$(date +%Y%m%d-%H%M%S)
3. 改 cwd + 迁移会话数据
路径 sanitize 规则:去首斜杠、/ 换 -(例:/Users/x/WorkBuddy/abc → Users-x-WorkBuddy-abc)。
Python 脚本要点:
UPDATE sessions SET cwd=? WHERE cwd IN (源路径...)—— 加PRAGMA busy_timeout=12000,用事务提交。- 把
~/.workbuddy/projects/<sanitize(源路径)>/下的文件shutil.move到
~/.workbuddy/projects/<sanitize(目标路径)>/,同名跳过,迁完删空目录。 - 写一份迁移映射到
~/.workbuddy/projects-move-manifest.json便于回退。
必须先退出 WorkBuddy(否则数据库被占用会写失败),或至少确认无写入冲突。
4. 校验
- 目标路径会话数 == 源空间会话总和
- 源路径剩余会话 == 0
- 会话数据文件已落到新目录且文件数一致
5. 让用户重启
彻底退出(菜单栏 → 退出,确认进程结束)再打开,空间列表才会刷新。
回退方式
# 恢复数据库
cp -p ~/.workbuddy/workbuddy.db.bak-<时间戳> ~/.workbuddy/workbuddy.db
# 按 projects-move-manifest.json 把文件移回原位
⚠️ 最关键的坑:不要删源文件夹(血泪教训)
会话历史 ~/.workbuddy/projects/<sanitized(path)>/<sessionId>.jsonl 里写死了大量原目录的绝对路径
(实测单个会话里有 190–5400+ 处 /Users/.../WorkBuddy/<旧目录>/xxx)。把 sessions.cwd 改成新路径
不会改写这些引用。一旦删除旧文件夹(尤其是清空废纸篓),打开任务就会报「文件已被删除」。
所以:
- 合并空间只需改
sessions.cwd,不要顺手删源文件夹。 旧文件夹留着完全不影响侧边栏——
空间由 cwd 推导,旧路径下已无会话,它们只是"休眠目录",不再显示为空间。 - 若用户坚持要删,必须先做以下之一,再删:
- 批量改写会话历史里的路径引用(旧路径 → 目标路径下的对应子目录);
- 或把旧目录替换为指向新位置的符号链接(保持路径可解析,且不占双份空间)。 - 万一已经删了:只要备份还在(备份必须放在废纸篓之外,如
~/.workbuddy/backups/),
copytree还原到原路径即可完全恢复,任务立即可打开。
遗留清理(需用户明确确认后才做)
顺序必须是:整体备份 → 逐字节校验 → 分批移入废纸篓 → 清配置。
(但请先读完上面那节——默认建议是"只清配置条目,不删物理文件夹"。)
- 备份到一处(例:
~/.workbuddy/backups/space-merge-<时间戳>/),用shutil.copytree,
逐目录比对「文件数 + 总字节数」一致后才继续。 - 旧空间的物理文件夹(
~/WorkBuddy/<时间戳>/)不会被自动删除,需单独确认再清,
且必须进废纸篓(可恢复),不要rm。 workspace-display-names.json里旧的显示名条目可顺手移除(纯显示层,删了不影响数据)。
移入废纸篓的正确做法
import shutil
from pathlib import Path
home = Path.home()
src = home/'WorkBuddy'/d; dst = home/'.Trash'/d
shutil.move(str(src), str(dst)) # 直接移入废纸篓
坑:
- osascript -e 'tell application "Finder" to delete POSIX file "..."' 会被沙箱拦截,
报 -10004 权限违例,不要在这条路上耗时间。
- ~/.Trash 受系统保护:ls ~/.Trash、shell glob、os.listdir 都会失败/无匹配。
核验时要按已知路径直接 stat(Path.home()/'.Trash'/d),不要列举目录。
- 批量循环注意:zsh 默认不对未加引号的变量分词,for d in $VAR 会把整串当成一项。
用数组、显式列表,或用 Python 循环。
- 每批 ≤10 项,每批后核对文件数。
踩坑记录
- 先加配置条目 + 补
.workbuddy标记 → 重启后仍不显示,因为判定条件压根不是这些。 - 遇到 App 内部状态问题,直接读
/Applications/WorkBuddy.app/Contents/Resources/app.asar
里的实现(用 Python 二进制 find + 提取上下文),比猜快得多。 - 扫描
~/.workbuddy全库做 grep 会超时,要限定文件名/目录范围。