Feature/feishu topic workspace isolation#1551
Conversation
Use topic-scoped channel keys in thread isolation mode and migrate legacy chat-level bindings so workspace routing no longer leaks across topics. Co-authored-by: Cursor <cursoragent@cursor.com>
Copy chat-level bindings into new topics without deleting the original so unbound topics inherit a default and older versions retain rollback compatibility. Co-authored-by: Cursor <cursoragent@cursor.com>
chenhg5
left a comment
There was a problem hiding this comment.
Conclusion: Approve
Overall assessment: This PR fixes the #1543 pain point directly — same group chat, different topics now get independent workspace bindings. The reporter (n-wen) self-implemented with a copy-style migration that preserves the legacy chat-level binding as a default for new topics, which is the right tradeoff: existing users with a single chat-level binding get topic-scoped bindings automatically, but the original default stays so other channels and downgrades remain unaffected. CI is green, blast radius is contained (workspace_bindings + feishu adapter only), and test coverage directly exercises the user's scenario. Merge-ready.
Review scope:
- Reviewed all 10 files (3 prod, 4 test, 2 doc, 1 example config) with +328/-17.
- Cross-referenced #1543 (reporter pain point) and PM's direction-A guidance (workspace_bindings key with topic_id + partial migration fallback).
- Focused on correctness of per-topic binding isolation, backward-compat migration, cross-adapter scope, and idempotency under concurrency.
✅ What looks good:
- Direct #1543 coverage:
populateWorkspaceChannelKeysparsessessionKey(feishu:oc_chat:root:om_root) and emitsChannelKey = "oc_chat:topic:om_root". The newTestCUJ_H4_FeishuTopicsKeepWorkspaceBindingsIsolatedwalks the exact reporter scenario — A bindsworkspace-a, B bindsworkspace-b, both keep their own, unbinding A restores chat-default and doesn't affect B. That's the user-observable outcome and it's tested end-to-end. - Migration is copy-not-move:
MigrateChannelKeydeliberately preserves the source binding (bindingis copied viainherited := *binding) so other channels still see the chat-level default and a downgrade to an older version is non-destructive. The early-return guardif lookupLocked(newChannelKey) != nilmakes repeated migration a no-op — idempotent by design. - Concurrent topic inheritance is covered:
TestWorkspaceBindingManager_InheritChannelKeyConcurrentTopicsfiresMigrateChannelKeyfrom goroutines for two topics inheriting the same default. The test expects both to inherit — i.e. the function is reentrant-safe under them.mu.Lock()/saveLockeddiscipline. Good. - Cross-adapter scope is correct:
populateWorkspaceChannelKeyslives on*feishu.Platform, andLegacyChannelKeyis only set whenp.threadIsolationis true. The Lark test (TestLark_SessionKeyPrefix) assertsLegacyChannelKey == ""outside topic mode, and Telegram/Discord/WeChat never populate either key — so the migration is a no-op for them and their bindings are unaffected. - CardAction dispatch refactor: replacing three copies of
h := p.getHandler(); go h(p.dispatchPlatform(), &core.Message{...})withgo p.dispatchCoreMessage(&core.Message{...})deduplicates the dispatch path. This also ensurespopulateWorkspaceChannelKeysis invoked uniformly for card actions, otherwise the topic scope would be lost on interactive-card-driven messages. - Docs cover the upgrade story: both
config.example.tomlanddocs/feishu.mdexplicitly call out that the chat-level binding is preserved as the default, so users understand why downgrading still works.
🟠 Should improve (non-blocking):
- Migration runs on every message:
migrateLegacyWorkspaceBindingsis called unconditionally inhandleMessagewhenevermultiWorkspaceis true. The early-return guards make it cheap, but it does takem.muand callrefreshLockedper message. Consider an in-memory "already-migrated" set (keyed by(projectKey, oldKey, newKey)) so the second message in a topic doesn't even attempt the lock. Not a blocker for correctness; matters only for high-throughput deployments. - Doc comment on
MigrateChannelKey: the doc comment says "copies an existing default binding" but does not mention that the source binding is preserved. A reader scanning the comment could reasonably assume this is a move and accidentally rely on it. Recommend tightening the comment to: "Copies the source binding to the destination; never deletes the source. Idempotent: returns false if the destination already exists." - No integration test for "downgrade safety": there's a unit test that the destination survives a reload, but not one that simulates rolling back to an older cc-connect version (where
LegacyChannelKeydoesn't exist) and confirming topic bindings still resolve. A round-trip test wherepopulateWorkspaceChannelKeysis skipped and the lookup still returns the topic binding would lock that contract in. parseThreadRootIDerror path: whenparts := strings.SplitN(rctx.sessionKey, ":", 3)doesn't yield 3 parts or the chat ID doesn't match,populateWorkspaceChannelKeyssilently returns without settingLegacyChannelKey. The no-threadIsolationbranch is the common case so this is fine, but a debug log when the parse fails (e.g. unexpected sessionKey shape from a future adapter) would help diagnose configuration drift.- Lock granularity under migration:
migrateLegacyWorkspaceBindingswalks[]string{"project:" + e.name, sharedWorkspaceBindingsKey}and callsMigrateChannelKeyonce per project, each takingm.mu.Lock(). Sequential rather than batch. For multi-project configs that's two lock/unlock cycles per message. Fine in practice; flagging for completeness.
🔵 Optional:
- Consider exposing a CLI subcommand like
/workspace migrate-nowfor users who want to backfill topic bindings without sending a message (e.g. after manually copying a workspace dir). Skip if not requested. - The whitespace-only fixes in
cuj_test.go(struct alignment incujAgentand twoMessageliterals) are gofmt-driven and harmless; just noting they appear in this PR to keep blame clean.
❓ Questions:
- None blocking. Reporter self-implementation shows clear understanding of the pain point and the migration trade-off. Open to author input on whether the "already-migrated" set is worth adding now or after observing hot-path metrics.
Testing / Risk:
- Verified evidence: 5/5 CI green (lint / unit-test / smoke-test / regression-test / performance-test, run 29399389477, 2026-07-15T07:34–07:43Z). New tests cover: per-topic binding isolation end-to-end (
TestCUJ_H4), migration copy + project-and-shared coverage (TestMigrateLegacyWorkspaceBindings_CopiesProjectAndSharedDefaults), reload-after-migrate round-trip, no-overwrite-on-conflict, and concurrent inheritance. Existing tests for thread-isolation session keys (TestLark_ThreadIsolationUsesRootSessionKeyetc.) updated to assert the new fields. - Remaining risk: production migration behavior under very long-running sessions with thousands of
workspace_bindings.jsonentries — the manager reads the whole file onrefreshLocked. If a single config grows large, the per-message overhead grows proportionally. Worth a perf follow-up if real users report it; the unit-test suite doesn't cover that scale. - Cross-adapter risk: confirmed none. Only
*feishu.Platformpopulates the new fields; other adapters leave them empty and the migration is a no-op.
Next step:
- Approve. Merge-ready. Owner can merge as-is or wait for v1.6.0/v1.5.1 release window per their discretion; this PR is well-isolated and the migration story is safe.
Summary
修复飞书开启
thread_isolation与 multi-workspace 同时使用时,同一群内不同 Topic 共用 workspace 的问题。Topic 现在使用独立的 workspace binding key;已有 chatId 级 binding 会保留为默认值,并复制给首次访问、尚未显式绑定的 Topic,兼容旧版本回退。Type of change
Testing
Automated tests added in this PR
TestLark_ThreadIsolationUsesRootSessionKeyinplatform/feishu/platform_test.go— 断言 Topic 消息生成独立ChannelKey与默认 chat key。TestLark_GroupReplyAllWithThreadIsolationUsesRootSessionKeyWithoutMentioninplatform/feishu/platform_test.go— 覆盖 group reply-all 下的 Topic key。TestInteractivePlatform_CardActionUsesCallbackSessionKeyinplatform/feishu/platform_test.go— 断言卡片回调保留 Topic workspace key。TestWorkspaceBindingManager_InheritChannelKeyPreservesDefaultincore/workspace_binding_test.go— 断言 Topic 继承默认 chat binding,且不删除默认 key。TestWorkspaceBindingManager_InheritChannelKeyDoesNotOverwriteDestinationincore/workspace_binding_test.go— 断言显式 Topic binding 不会被默认值覆盖。TestWorkspaceBindingManager_InheritChannelKeyConcurrentTopicsincore/workspace_binding_test.go— 断言多个 Topic 可并发继承默认 binding。TestMigrateLegacyWorkspaceBindings_CopiesProjectAndSharedDefaultsincore/multi_workspace_test.go— 覆盖 project 与 shared binding 默认值复制。TestCUJ_H4_FeishuTopicsKeepWorkspaceBindingsIsolatedincore/cuj_test.go— 覆盖 Topic 默认继承、独立 bind、查询与 unbind 回退默认值。For bug fixes only — regression test
TestCUJ_H4_FeishuTopicsKeepWorkspaceBindingsIsolatedCritical User Journeys (CUJ) impact
/new/switch/list/historyetc.)/mode/cancel/stoppermissions)allow_fromadmin_frombanned_wordsrate limits)/cron/timer)/lang/provider/modelreload)If any CUJ group is touched, confirm:
go test ./core/ -run TestCUJpasses locally.CUJ test was updated (or a new CUJ added) to cover the new behavior.
Manual / user-visible behavior change
在同一个飞书群内:
/workspace bind <workspace>绑定独立 workspace。/workspace unbind后回退到 chatId 默认 workspace。Checklist (reviewer will verify)
go build ./...passesgo test ./...passes (with-raceif touching concurrency)core/Related
Fixes #1543