Joye Dev

Back

Workspace feature flags:从多处配置到可管理的生成式契约

结论先行:#5015 把 workspace feature 从“Go YAML、前端默认值、Agent 环境判断分别维护”的分散开关,推进成一条可管理的 capability control plane:TypeSpec catalog 是手写来源,custom emitter 生成 Go runtime 与共…


结论先行:#5015 把 workspace feature 从“Go YAML、前端默认值、Agent 环境判断分别维护”的分散开关,推进成一条可管理的 capability control plane:TypeSpec catalog 是手写来源,custom emitter 生成 Go runtime 与共享 TypeScript fallback,Admin 通过严格的全量替换 API 写入 workspace override,运行时按 feature 粒度容错。#4797 提供持久化和消费端 gate,#5009 证明新增 Sheet 这类具体能力可以沿用同一垂直切片。

业务线概览#

产品问题不是简单地“在生产环境隐藏一个入口”,而是让同一套 Voyager 能够按环境和 workspace 控制 Docs、Sheet 等仍在逐步开放的能力:内部 workspace 可以试用,生产默认关闭;用户侧的创建入口、已有页面访问和 Agent 能力还要保持一致;平台管理员不能再直接改数据库。

本次范围覆盖三条已经进入 origin/main 的代码证据:

这条线不覆盖真正的权限/安全授权、计费 entitlement 或管理员审计日志。#4797 的 PR 说明已明确 feature gate 是 best effort 的产品开关,不应被当成安全门。

今日锚点#

  • PR:#5015 Allow admins to edit workspace feature overrides
  • **作者:**luin
  • **Merge:**2026-07-22T05:07:06Z,即 Australia/Melbourne 2026-07-22 15:07:06 AEST
  • **merge commit:**fc4c32db44235895fbccc568e1c9d9e6d16ce8a0,已验证可达当前 origin/main
  • **改动范围:**74 个文件,1,338 additions / 299 deletions;跨 TypeSpec、OpenAPI/Go/TypeScript codegen、workspace service/repository、Admin handler/UI、site fallback 和 Agent visibility。

选择它的理由是:今天窗口里它不是单个入口的 UI 修改,而是同时触及“谁定义 feature、谁持久化、谁解析、谁能写、消费者如何收敛”的完整边界。#5015 的 PR 描述写着依赖一个非 main 的中间分支 PR #5037;该 PR 的 base 是 zh-admin-workspace-feature-overrides,其 merge commit 不在当前 origin/main,因此本报告只使用已经进入 origin/main 的 #5015、#4797、#5009 作为代码证据。

演进时间线#

  1. 前置基础:#4797,2026-07-21T07:20:14Z 合入(墨尔本 17:20:14)。 000086_add_workspace_feature_overrides 给 workspaces 增加 feature_overrides JSONB NOT NULL DEFAULT ’{}‘,并用 jsonb_typeof(…) = ‘object’ 约束持久化形状。Go workspace model 同时保留 raw override 和解析后的 Features;service 在 workspace create/get/list/update 后解析环境 default 与 override。API 暴露匿名可读的环境默认值和成员可读的 /api/workspaces/{id}/features。site 用 useWorkspaceFeatures 隐藏 Docs 创建入口,Agent 则根据 workspace features 过滤 document skill、insert_page schema 和 update_doc tool。
  2. 具体能力扩展:#5009,2026-07-21T09:53:21Z 合入(墨尔本 19:53:21)。 在同一个 workspace contract 中增加 isSheetEnabled,同步扩展 Go model、各环境配置、API 生成类型和 contract/service tests。site 的 CreateFileModal 与编辑器 PageThumbnailsBar 都根据当前 workspace 的 isSheetEnabled 决定是否展示 Sheet;PR 的测试说明强调开关只控制 UI 入口,已有 Sheet 和直链访问仍保持可用。这一层说明 feature gate 的责任边界是产品可见性,而不是后端资源授权。
  3. 当前锚点:#5015,2026-07-22T05:07:06Z 合入(墨尔本 15:07:06)。 Admin 用户详情页可以查看每个 workspace 的 raw overrides 和最终生效 features,并通过 PUT /admin/workspaces/{id}/feature-overrides 进行完整替换;{} 表示清空 override、恢复环境默认。写入侧使用生成的 closed-object schema,拒绝未知字段、错误大小写、错误类型和 null。读取侧改成逐字段 sanitizer:一个损坏 key 不再阻塞整个 workspace 响应,合法的 sibling override 继续生效。与此同时,TypeSpec emitter 生成 Go DefaultFeatures/ResolveFeatures 和 packages/common fallback,删除重复的 Go YAML feature 配置,并将 codegen:check 接入 CI。

**当前阶段判断:**这条业务线已从“能按 workspace 关闭 Docs/Sheet”进入“可以由平台管理员安全调整、并由单一契约驱动多运行时”的基础设施阶段;还没有进入审计、权限 enforcement 或 feature 生命周期治理阶段。

当前架构与数据流#

flowchart LR
  A["TypeSpec WorkspaceFeatures catalog"] --> B["custom workspace-feature emitter"]
  B --> C["Go DefaultFeatures / ResolveFeatures"]
  B --> D["common TS fallback + generated API types"]
  E["Admin dialog + generated Zod"] --> F["PUT feature-overrides"]
  F --> G["workspace.feature_overrides JSONB"]
  G --> C
  C --> H["workspace.Features"]
  H --> I["workspace list / /api/me"]
  H --> J["GET /api/workspaces/{id}/features"]
  I --> K["site WorkspaceContext + UI gates"]
  J --> L["agent getWorkspaceFeatures"]
  L --> M["visibleSkills + tool schema"]
plaintext
  1. **契约与生成:**idl/typespec/services/workspace.tsp 的 WorkspaceFeatures 是唯一声明 feature 名称和类型的位置;@workspaceFeature 同时携带 fallback 与环境默认。WorkspaceFeatureOverrides 用 OptionalProperties 表达“只提交差异”,并以 additionalProperties: false 保持 closed-object 语义。emitter 从 TypeSpec model property 读取 metadata,生成 Go runtime 和 packages/common/src/gen/workspaceFeatures.gen.ts;OpenAPI/Go/TS client 仍由既有 codegen pipeline 产生。
  2. **持久化与解析:**feature_overrides 只存 workspace 的偏差,不把环境默认重复写入数据库。NewFeatureDefaults 根据 app.Env 调用 generated DefaultFeatures;workspace service 对每个返回路径调用 ResolveFeatures,先复制 defaults,再应用 sanitizer 返回的合法 override。
  3. **API 与 Admin:**普通用户的 workspace list 由 mapper 把 ws.Features 放入 API model;成员可以读取单 workspace 的 resolved features。Admin 列表同时返回 SanitizeFeatureOverrides 后的 overrides 与 resolved features,写入 API 则在 handler 层反射生成类型字段做严格校验,service 只负责序列化并交给 repository 更新 JSONB。
  4. **site 与 Agent 消费:**site 的 WorkspaceContext 保存当前 workspace 的 features,useWorkspaceFeatures 让创建弹窗和编辑器加页菜单读取同一对象。Agent 请求开始时并行做 credit gate 与 getWorkspaceFeatures,随后 visibleSkills 过滤模型可见的 skills;buildAgentTools 再由可见 skill 推导 CODE/DOC 的 tool schema,因此模型不会看到被 workspace 关闭的 DOC 能力。Sheet 当前只接入 site UI gate,Agent visibility 类型也只依赖 isDocsEnabled。

关键代码#

1. #4797 把 workspace override 变成有形状的持久化状态#

来源:#4797 的真实 diff,backend/base/db/migration/000086_add_workspace_feature_overrides.up.sql:1-9。

ALTER TABLE workspaces
ADD COLUMN feature_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT workspaces_feature_overrides_valid CHECK (
    jsonb_typeof(feature_overrides) = 'object'
) NOT VALID;
plaintext

代码事实:DB 只保证它是 JSON object,不试图在迁移层枚举 feature 名称;NOT VALID 避免为已有行做阻塞式验证,同时约束未来写入。设计推断:feature schema 留在应用/API 层,数据库只承担可迁移的容器约束,这为后续增加字段保留空间。

2. #5015 把名称、类型、环境 default 集中到 TypeSpec catalog#

**来源:**idl/typespec/services/workspace.tsp:22-47,对应 #5015 diff。

model WorkspaceFeatures {
  @workspaceFeature(#{
    defaultValue: false,
    environmentDefaults: #{ local: true, test: true, dev: true },
  })
  isDocsEnabled: boolean;
}

@extension("additionalProperties", false)
model WorkspaceFeatureOverrides is OptionalProperties<WorkspaceFeatures>;
plaintext

代码事实:catalog emitter 只接受直接的 boolean | int32 property,缺少 decorator、可选字段、错误 default 类型都会让 codegen fail closed(idl/typespec/emitter/workspace-features.mjs:56-139)。生成结果包含 Go default/fallback 和 shared TS fallback,不再让 site/agent 各自手写 feature defaults。

3. #5015 的 runtime 选择“逐字段容错”,避免坏 override 阻塞 workspace#

**来源:**当前 origin/main 的 backend/go/internal/core/workspace/features.gen.go:39-75,由 #5015 emitter 生成。

func ResolveFeatures(defaults Features, raw json.RawMessage) Features {
    resolved := defaults
    overrides := SanitizeFeatureOverrides(raw)
    if overrides.IsDocsEnabled != nil {
        resolved.IsDocsEnabled = *overrides.IsDocsEnabled
    }
    if overrides.IsSheetEnabled != nil {
        resolved.IsSheetEnabled = *overrides.IsSheetEnabled
    }
    return resolved
}

func decodeFeatureOverride[T any](fields map[string]json.RawMessage, name string) *T {
    raw, ok := fields[name]
    if !ok { return nil }
    var value *T
    if err := json.Unmarshal(raw, &value); err != nil { return nil }
    return value
}
plaintext

代码事实:整个 raw JSON 不是 object 时返回空 overrides;单个字段类型错误或 null 时返回 nil;其他合法字段不会被连坐。与之相对,Admin 写入路径在 backend/go/apps/api/handler/admin_workspaces.go:17-69 严格拒绝这些输入。这个“写严格、读容错”组合把操作员错误挡在边界上,同时把历史脏数据从用户主请求路径中隔离。

4. #5015 把 Admin 写入定义为完整替换,而非 patch#

**来源:**backend/go/apps/api/handler/admin_workspaces.go:17-69、backend/go/internal/core/workspace/repo.go:254-265。

for name, value := range fields {
    if _, ok := allowedFields[name]; !ok {
        return bizerrors.BadRequest("unknown workspace feature override: "+name, nil)
    }
    if bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
        return bizerrors.BadRequest("workspace feature override cannot be null: "+name, nil)
    }
}
...
feature_overrides: gorm.Expr("CAST(? AS jsonb)", string(overrides)),
plaintext

Admin contract test 覆盖了保存 isDocsEnabled: false、用 {} 清空、错误类型/未知 key/错误大小写/null 返回 400,以及 workspace 不存在返回 404(backend/go/apps/api/contract_test/admin/users_test.go:59-114)。完整替换语义让“当前 override 集合”可直接由 UI 文本框表示,但也意味着并发管理员编辑需要后续的版本或审计设计。

5. #5009 让 site 的多个入口读取同一 resolved feature#

来源:#5009 diff;当前 packages/site/src/app/(main)/_components/CreateFileModal.tsx:195-211 和 packages/site/src/app/(main)/files/[id]/PageThumbnailsBar.tsx:276-277。

const { isDocsEnabled, isSheetEnabled } = useWorkspaceFeatures();
const categories = CATEGORIES.filter(({ key }) => {
  if (key === selectedCategoryKey) return true;
  if (key === "DOC") return isDocsEnabled;
  if (key === "SHEET") return isSheetEnabled;
  return true;
});
plaintext

这里刻意只控制创建入口;编辑器加页菜单也读取 isSheetEnabled,但已有 Sheet 仍可通过页面或直链访问。代码事实与 #4797 的 best-effort 约束一致:不能把“入口隐藏”误读成“服务端拒绝所有相关资源”。

6. #4797 把 workspace capability 传到 Agent,而不是只在 UI 隐藏#

**来源:**当前 backend/workers/agent/src/agent.ts:912-1024、backend/workers/agent/src/skillVisibility.ts:10-24。

const [gateError, workspaceFeatures] = await Promise.all([
  this.creditGateResponse(go),
  getWorkspaceFeatures(go),
]);
...
const skills = visibleSkills(this.env, workspaceFeatures);
plaintext

visibleSkills 对 production workspace 仍会保留已发布能力,只按 isDocsEnabled 去掉 document skill;buildAgentTools 再据此决定 insert_page 的 enum、update_doc 和 load_skill 的可见集合。这个边界比“模型仍看到工具、执行时再报错”更稳定,也把 feature decision 放在任何模型调用前。

工程取舍#

  • **单一来源 vs 自定义 codegen:**把默认值放在 TypeSpec decorator 后,新增 feature 只需改 catalog;代价是维护一个 emitter 和完整的 TypeSpec → OpenAPI → Go → TypeScript pipeline。#5015 通过 pnpm codegen:check 重新生成并检查 tracked/untracked drift,把“忘记同步某个 package”变成 CI failure。
  • **写入严格 vs 读取容错:**Admin API 只接受生成 schema 中的精确 camelCase 字段、正确类型和非 null 值;runtime 不信任已有 JSON,按 key sanitize,保证一个坏 key 不阻塞 /api/me 或 Agent 的 workspace 读取。这是兼容线上脏数据的明确取舍,不是把 schema 校验全部下沉到数据库。
  • **环境 default vs workspace override:**数据库只保存偏差,DefaultFeatures(app.Env) 负责 local/test/dev 与未知环境 fallback。site 的 public default API 有 5 分钟 TTL,失败时优先使用 last-known,再退到 generated fallback;因此可用性更好,但 flag 变更不是强一致的实时广播。
  • **UI gate vs 安全边界:**Docs/Sheet 入口和 Agent tool visibility 是产品能力开关;PR 没有把它提升成 API authorization。任何会产生费用、写入敏感数据或触发高权限动作的能力,仍需在真实 handler/service 上做独立授权。
  • **完整替换 vs 并发编辑:**PUT 的 {} 清空语义简单可读,Admin UI 可以把整个 JSON 文本框直接提交;代价是没有版本号、patch merge 或审计日志,两个管理员同时编辑时后写者覆盖前者。
  • **性能与状态 owner:**workspace features 随 workspace 查询返回,Agent 只在 turn 开始获取一次;没有每个 tool 的重复 API 请求。site 与 Agent 各自持有缓存/请求生命周期,减少耦合,但需要验证 Admin 修改后的旧缓存窗口。

和最近学习记录的关系#

  • 本地 study log 在本次运行前已有 19 个锚点,#5015、#4797、#5009 均未作为锚点记录,因此没有重复学习同一 PR。
  • 2026-07-21 的 #4757 CODE presentation 和本线都涉及多端 runtime capability,但 #4757 的中心是跨域 iframe 输入、Present viewport 和消息协议;本次新增的是 workspace-level control plane,不把两个主题硬拼成一条线。
  • 2026-07-20 的 #4938 PNG export scale 同样讨论“产品开关和调用方契约”,但它的证据在 export/billing/worker 边界,和 workspace feature persistence 没有直接代码关系,因此只作为方法上的对照。
  • #4797 与 #5009 都发生在本次墨尔本“今天”窗口内,但它们是 #5015 的自然前置/并行证据;报告新增的视角是从具体开关扩展到 catalog、Admin 控制面和读写可靠性。

我会怎么吸收#

  1. 对跨 package 的开关,先画出“声明、默认、持久化、解析、API、消费者、测试”的垂直切片,再决定是否值得做统一 codegen。
  2. 把持久化 schema 的约束分层:写入边界严格拒绝新错误,读取路径对旧数据逐字段降级,避免把历史脏数据升级成全局可用性事故。
  3. 用 capability 集合改变 Agent 的 tool/schema 暴露面,让模型在规划阶段就看不到不可用能力;不要等执行阶段返回一个笼统错误。
  4. 明确 feature gate 不是 authorization。凡是成本、隐私或数据写入风险,必须沿真正 API/service 边界另做强校验。
  5. 为生成文件增加 drift check,并把“新增一个 feature 是否同时更新 Go、TS、API 和测试”变成可验证的 CI 约束。

边界、风险、未解问题#

  • 命名兼容风险:#4797 初始测试/Go model 使用 snake_case raw key,#5015 将 canonical override 改为 TypeSpec/OpenAPI 的 camelCase;PR 说明当前没有历史 override,因此没有迁移。若环境里已经存在旧 key,当前 sanitizer 会忽略它,可能导致 feature 意外回到 default。
  • 安全语义:#4797 明确 best effort;已有页面和直链不因关闭 Sheet 而被拒绝。需要盘点是否有某些 feature 实际上影响费用、数据写入或高权限操作,并在后端增加独立 enforcement。
  • **一致性窗口:**site public defaults 有 5 分钟缓存,workspace list 与 Agent turn 也有各自请求/缓存生命周期;Admin 保存后,用户下一次渲染看到旧值多久仍待实测。
  • **并发与治理:**完整替换没有 optimistic concurrency、变更 diff 或 audit log;目前 #5015 只覆盖“能保存和清空”,没有管理员互相覆盖、撤销和追责模型。
  • **emitter 的未来类型:**当前允许 int32,但 validateValue 只判断 typeof value === “number”,并通过拒绝 range decorator 限制复杂性;引入第一个整数 feature 前应补整数/range 校验和生成测试。
  • **验证范围:**本次只读 origin/main 和 PR diff,未运行 Voyager 测试、codegen 或本地服务;报告中的测试结论来自已合入的测试文件与 PR Test Plan,不能替代本次独立执行。

候选说明#

  • **窗口:**按 Australia/Melbourne 计算,今日窗口从 2026-07-21T14:00:00Z 开始,优先查看当天已合入 PR;今天已有足够的高价值候选,因此没有扩大到更早日期。
  • **去重:**读取了 /Users/joye/.codex/automations/voyager-merged-pr/study-log.jsonl;已有锚点包括 #4757、#4938、#4844、#4895 等,本次未重复。
  • **筛选:**今天窗口同时出现 #4797、#5009、#5015、#5001/#5005 等可学习变更。#5001/#5005 是 CODE 表单的另一条自然 stacked line;#5015 的跨层架构信号更强。#5037 虽然标题和代码高度相关,但它合入的是 zh-admin-workspace-feature-overrides 中间分支,不满足“已进入 origin/main”的锚点/关联 PR 边界。
  • 最终选择:#5015 能自然串起 #4797 的存储/运行时基础和 #5009 的具体 flag 扩展,并把业务线推进到 Admin 可操作、生成文件可校验、损坏配置可降级的阶段;没有为了凑数加入不共享数据模型或 runtime boundary 的 PR。

GitHub 文档#

  • **报告文件:**outputs/voyager-daily-pr-study/2026-07-22-pr-5015-workspace-feature-catalog-admin-overrides.md
  • Voyager PR:#5015
  • **阅读基线:**origin/main at 17c8a23ca27381e01241545713159c84d26bd8a5;锚点 commit fc4c32db44235895fbccc568e1c9d9e6d16ce8a0
  • **GitHub commit:**b576868(本报告文件首次写入的 commit);本次自动化最终仓库 HEAD 由 study-log 的 githubCommit 字段记录。

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back