RunAnywhere IDL 指南:39 个 Proto 文件驱动的跨平台 SDK 代码生成与 Schema 演化规范
RunAnywhere IDL 指南:39 个 Proto 文件驱动的跨平台 SDK 代码生成与 Schema 演化规范
本文是 RunAnywhere SDK 单仓库中
idl/目录的完整技术指南,面向需要在 idl/AGENTS.md 指引下新增或修改.proto文件、理解代码生成管线与 Schema 版本演化的工程师。读完本文,你将掌握:如何编写符合八个语言选项块要求的 proto 文件、如何通过rac_*自定义注解为六个 SDK 生成defaults()/validate()/wireString等便捷 API、如何用SCHEMA_LOCK与 semver 门禁保证跨平台类型不漂移,以及如何在buf、黄金文件测试与 drift-check 三重防护下安全地演化 Schema。
1. idl/ 是什么:一个 Schema 根,六种语言的绑定源头
RunAnywhere SDK 是一个跨平台端侧 AI 单仓库:一个 C/C++ 核心库(runanywhere-commons)实现了全部 AI 业务逻辑,并通过纯 C ABI(rac_* 前缀)被 Swift、Kotlin、Flutter、React Native、Web、Python 六个 SDK 调用。而让这六个语言生态共享同一套"模型类型、错误类型、事件类型、选项类型"的关键,就是 idl/ 目录下的 39 个 .proto 文件(package runanywhere.v1)——它是整个 monorepo 中每一个跨平台类型的单一 Schema 根。
从仓库根目录的 AGENTS.md 可以确认这一架构定位:
idl/*.protois the schema root;idl/codegen/generate_all.shemits per-language bindings (Swift, Kotlin/Wire, TS/ts-proto, Dart), all generated and none committed
idl/AGENTS.md(也就是 idl/CLAUDE.md 所指的同一份文件)进一步明确了 Schema 根覆盖的范围:模型类型、错误类型、事件类型、选项类型,以及 LLM/VLM/STT/TTS/VAD 配置和 SDK 事件目录。idl/codegen/ 目录中的生成器把这些 Schema 渲染成 Swift、Kotlin(Wire)、TypeScript(ts-proto)、Dart、C++、Python 六种语言绑定。
在动手修改任何 .proto 之前,idl/AGENTS.md 明确要求先读 docs/reference/generated-code-contract.md——它覆盖"每个脚本生成什么、落在哪里、工具链如何固定、idl/VERSION 与 idl/SCHEMA_LOCK 漂移门禁如何工作"。idl/AGENTS.md 本身不重复这些内容,只覆盖 proto 编写约定和 codegen/ 中超出 generate_all.sh 的部分。
1.1 生成代码不提交:为什么需要这个约定
与大多数"生成后提交"的仓库不同,RunAnywhere 的约定是生成代码一律不提交(gitignored)。因此:
- 全新 clone 在 codegen 运行前没有任何 C++/Kotlin/Swift/TypeScript/Dart/RN/Python 绑定;
scripts/setup/setup.sh第一步就运行 codegen(./run codegen可随时按需触发);- 每个发布路径(
npm pack、flutter pub publish、wheel 打包)在打包前必须调用idl/codegen/ensure_generated.sh --only <lang>,否则会打包出空dist/、无lib/generated/的包,或"安装成功但 import 失败"的 wheel; - 既然绑定不提交,
git status就无法再作为"是否重新生成过"的信号——这正是idl/SCHEMA_LOCK存在的根本原因(见第 4 节)。
各语言生成物落点(来自 docs/reference/generated-code-contract.md):
| 生成树 | 谁生成 | 何时生成 |
|---|---|---|
core/src/generated/proto/(76 个文件,约 336k 行) |
core/CMakeLists.txt,configure 时若文件缺失则生成 |
每次 cmake --preset …,即全部约 29 个原生 CI runner、Electron addon、Python wheel、C++ desktop kit、WASM |
core/include/rac/rac_defaults_generated.h |
同一 CMake 块 | 同上。这是一个对外发布的公共头文件:install(DIRECTORY include/) 会把它放进 XCFramework 的 Headers/ 和 Linux/Windows 发行包,且五个对外头文件 rac_{llm,stt,tts,vad,vlm}_types.h 会 #include 它 |
bindings/kotlin/.../sdk/generated/(373 个文件) |
generateIdlKotlinBindings Gradle 任务,挂在 preBuild |
每次 assemble* / compile*Kotlin / test* / ktlint / detekt,包括 JitPack |
bindings/swift/Sources/RunAnywhere/Generated/ |
sync-dist-repo.sh |
随 SwiftPM tag 发布 |
bindings/proto-ts/src/ 与 dist/ |
每次 package-sdk.sh |
dist 随 7 个 npm 包发布 |
bindings/flutter/packages/runanywhere/lib/generated/ |
bindings/flutter/scripts/package-sdk.sh |
随 pub 包发布 |
bindings/flutter/ 与 bindings/react-native/ 下的两个 RADefaultsPool.kt |
同样的打包脚本 | 随 pub / npm 包发布 |
bindings/python/runanywhere/_proto/、_generated_{errors,defaults}.py |
树内 PEP 517 后端 | 随 sdist + wheel 发布 |
idl/codegen/generated_trees.txt 是这张表的机器可读版本,并额外列出这些树内保持跟踪的 8 个手写文件(.gitignore 的取反规则就是为它们存在;check_generated_trees.sh 在"某个生成文件被提交"或"某个应跟踪文件停止被跟踪"时都会失败)。
2. 新增或修改一个 .proto 的四步流程
idl/AGENTS.md 把 proto 编写流程浓缩为四个要点,全部以 wire 兼容性和跨语言一致性为核心。
2.1 第一步:复制八个选项块样板
新建文件时,直接从一个现有文件(如 idl/pipeline.proto)复制 option 块样板。八个选项缺一不可、必须一致,因为每个生成器都依赖它们全部存在——缺一个的文件会在对应语言里静默地得到错误的默认命名:
syntax = "proto3";
package runanywhere.v1;
option cc_enable_arenas = true;
option java_multiple_files = true;
option java_package = "ai.runanywhere.proto.v1";
option java_outer_classname = "PipelineProto";
option objc_class_prefix = "RAV1";
option csharp_namespace = "Runanywhere.V1";
option swift_prefix = "RA";
option go_package = "github.com/runanywhere/runanywhere-sdks/idl/v1;runanywherev1";
以上是 idl/pipeline.proto 的真实样板(java_outer_classname 按文件名替换)。八个选项的作用与后果:
| 选项 | 影响的语言 | 缺失后果 |
|---|---|---|
syntax = "proto3" |
全部 | 语法模式错误,生成器行为不可预期 |
package runanywhere.v1 |
全部 | 类型命名空间错乱,跨文件引用失效 |
cc_enable_arenas |
C++ | C++ 生成物 arena 语义变化 |
java_multiple_files |
Kotlin/Java | Java 类组织方式变化 |
java_package |
Kotlin/Java | Java 包路径错误 |
java_outer_classname |
Kotlin/Java | 外层类名冲突或错误 |
objc_class_prefix |
Swift/Objective-C | 前缀不统一 |
csharp_namespace |
C#(若将来有) | 命名空间不一致 |
swift_prefix |
Swift | Swift 类型前缀丢失 |
go_package |
Go(若将来有) | 包路径错误 |
2.2 第二步:编辑消息/枚举后,更新 idl/VERSION 并运行生成器
编辑完 message/enum 后,必须手工维护 idl/VERSION 的 semver(当前为 1.3.0,见 idl/VERSION),它描述的是 Schema 表面的演化等级:
- patch:仅注释/文档变更;
- minor:新增字段/消息/枚举值(additive);
- major:wire 破坏性变更。
然后运行 ./idl/codegen/generate_all.sh——它会顺带重写 idl/SCHEMA_LOCK。CI 中用 schema_lock.sh --check --require-bump <base-lock> 强制执行这个 patch/minor/major 区分(详见第 4 节)。
generate_all.sh 还支持两个实用参数(来自 idl/codegen/generate_all.sh 头部注释):
./idl/codegen/generate_all.sh # 默认全部六种语言
./idl/codegen/generate_all.sh --skip-dart # 本地没有 Dart 3.0+ 时跳过 Dart(CI 在固定工具链上补生成)
./idl/codegen/generate_all.sh --only ts,cpp # 只生成 TS 与 C++(子集相互独立,互不干扰)
2.3 第三步:永远不要重编号或复用字段号
对 wire 稳定或持久化的消息,字段号是负载承载的,不是装饰:
- idl/model_types.proto 的
ModelInfo子图会被序列化为磁盘上的.rac-manifest.binpb,因此它的reserved块是承载性的——移除一个字段必须把它加进reserved,绝不能直接删号; - idl/sdk_events.proto 对整个文件声明同一规则:字段号、消息名、枚举值全部 wire 稳定,只有注释/分组可以改动;
- idl/errors.proto 的数值必须与
core/include/rac/core/rac_error.h逐一对齐——改变已有值就是 wire break,新增一个值才是安全的。
从仓库中可见 reserved 的实际用法,例如 idl/download_service.proto 中 reserved 2; // was DownloadStage stage: duplicated state (8)、idl/llm_options.proto 中 reserved 10; // was string finish_reason -- wire-type flip to enum, fresh tag below——都是"旧字段号只保留在 reserved 里,新语义用新 tag"的实践。
2.4 第四步:service 块是逻辑契约,不是 RPC
整个仓库只有一个 .proto 带 service 块:idl/tool_calling.proto 的 ToolCalling:
// Logical tool-calling service contract. Host tool registration, permission
// checks, execution, callbacks, browser/native APIs, and side effects remain
// adapter-owned; this service describes only portable parsing, prompt
// formatting, and validation semantics over generated messages.
service ToolCalling {
rpc Parse(ToolParseRequest) returns (ToolParseResult);
rpc FormatPrompt(ToolPromptFormatRequest) returns (ToolPromptFormatResult);
rpc ValidateCall(ToolCallValidationRequest) returns (ToolCallValidationResult);
}
这是一个纯逻辑契约——只描述解析、prompt 格式化、校验的 message 形状。仓库里没有任何 gRPC 传输在运行:它已被进程内 C 回调路径取代(见根 AGENTS.md)。因此:
- 在别处新增
service只会生成没人调用的 stub 代码; - Wire 的 Kotlin 生成器会为
service { rpc ... }块产出 gRPC 接口(<Service>Client.kt),所以 idl/codegen/generate_kotlin.sh 会在生成后剥离这些 stub; - 真正会被消费的"服务"形态是各语言的消息流封装(如 TypeScript 的
AsyncIterable,见第 6 节templates/ts_async_iterable.njk)。
3. rac_options.proto:九个自定义注解,让默认值与校验从 Schema 直接长出来
idl/rac_options.proto 定义了 9 个 FieldOptions/EnumValueOptions 扩展,全部落在 Google 为组织私有扩展保留的 50000–99999 范围内(50003、50008–50009 留空作为未来扩展位):
extend google.protobuf.FieldOptions {
// Parsed per the field's declared type: "3" for int32, "0.7" for float,
// "true" for bool, "auto" for string, the constant name for an enum.
string rac_default = 50001;
// validate() rejects this field's proto3-zero value.
bool rac_required = 50002;
// Inclusive bounds for integer fields.
int32 rac_min = 50004;
int32 rac_max = 50005;
// Inclusive bounds for float/double fields.
double rac_min_float = 50006;
double rac_max_float = 50007;
}
extend google.protobuf.EnumValueOptions {
// Human-readable label for UI and docs.
string rac_display_name = 50010;
// Stable analytics key, so enum renames don't break analytics history.
string rac_analytics_key = 50011;
// Wire-string override for interop with hand-rolled string protocols.
// Codegen uses this instead of the proto constant name.
string rac_wire_string = 50012;
}
3.1 注解不是 protoc 直接消费的
关键设计:protoc 本身不消费这些注解。真正消费它们的是 codegen/generate_{swift,kotlin,dart,ts}_convenience.py 这四个后处理器——它们解析 protoc --descriptor_set_out --include_imports 产出的 descriptor set,按语言发射:
| 注解 | 发射的访问器 |
|---|---|
rac_wire_string(枚举值上) |
wireString(正向) + from(wireString:) / fromWireString(反向工厂) |
rac_display_name |
displayName |
rac_analytics_key |
analyticsKey |
rac_default(字段上) |
defaults() 工厂 |
rac_required / rac_min / rac_max / rac_min_float / rac_max_float |
validate() |
这套设计取代了原先各 SDK 各自手写的 helper,解决了一个真实的漂移问题:idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md(设计文档)记录,此前 Kotlin 的 RAConvenience.kt、Dart 的 stt_options_helpers.dart、sdk_environment.dart 等手写 helper 会随 proto 注解的演化而逐渐失配,Swift 有代码生成而其他 SDK 没有——PR #494 的审查结论(T3.3)正是要求把 Swift 的便捷生成能力平移到 Kotlin/Dart/TS。
一个实际的使用示例来自 idl/model_types.proto:
enum AudioFormat {
AUDIO_FORMAT_UNSPECIFIED = 0;
AUDIO_FORMAT_PCM = 1 [(runanywhere.v1.rac_wire_string) = "pcm"];
AUDIO_FORMAT_WAV = 2 [(runanywhere.v1.rac_wire_string) = "wav"];
AUDIO_FORMAT_MP3 = 3 [(runanywhere.v1.rac_wire_string) = "mp3"];
// ...
AUDIO_FORMAT_PCM_S16LE = 9 [(runanywhere.v1.rac_wire_string) = "pcm_s16le"]; // Android "pcm_16bit"
}
注意 rac_wire_string 的语义:它是"与手写字符串协议互操作时的 wire 字符串覆盖值",生成器用它替代 proto 常量名。所以 AudioFormat.AUDIO_FORMAT_PCM 的 wireString 是 "pcm",AUDIO_FORMAT_PCM_S16LE 是 "pcm_s16le"(与 Android 侧的 pcm_16bit 历史命名区分开)。
3.2 validate() 生成但不自动调用
idl/AGENTS.md 特别强调一个容易误解的点:
validate()is generated but never auto-invoked — callers opt in explicitly, and commons enforces its own bounds independently, so don't treat annotation coverage as the only validation layer.
即生成的 validate() 永远不会被自动调用——调用方必须显式调用;同时 C++ commons 会独立执行自己的边界约束。所以注解覆盖不是唯一的校验层,它只是 SDK 侧可选的、Schema 驱动的校验便利设施。这对调用方意味着:即使某个字段没打 rac_* 注解,也不代表底层不校验;反之,打了注解不代表每次调用都会自动校验。
3.3 跨语言输出形状差异(从设计文档与测试黄金文件验证)
idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md 是四个便捷生成器的契约文档,它逐语言规定了输出形状——三种语言的惯用法差异很大:
Kotlin(Wire)——companion 对象扩展函数,抛 SDKException:
public val AudioFormat.wireString: String
get() = when (this) {
AudioFormat.AUDIO_FORMAT_UNSPECIFIED -> "unspecified"
AudioFormat.AUDIO_FORMAT_PCM -> "pcm"
// …
}
public fun AudioFormat.Companion.fromWireString(value: String): AudioFormat? = /* … */
public fun STTConfiguration.Companion.defaults(): STTConfiguration = STTConfiguration(
language = STTLanguage.STT_LANGUAGE_EN,
sample_rate = 16000,
enable_punctuation = true,
enable_word_timestamps = true,
)
public fun STTConfiguration.validate() {
if (sample_rate < 8000 || sample_rate > 48000) {
throw SDKException.validationFailed(
"sample_rate must be in 8000...48000 (got $sample_rate)"
)
}
}
注意 Wire 的构造参数名是 snake_case(sample_rate 而非 sampleRate),生成器直接用 proto 字段名 field.name,与 Swift 的 camelCase 规则正好相反。
Dart(dart-protobuf)——Dart 的 extension 不能在目标类型上声明 static 方法,所以反向工厂是顶层函数,正向访问器是 instance extension getter;int64 字段必须用 package:fixnum 的 Int64(16000) 包装而不是裸 16000:
extension AudioFormatWireString on AudioFormat {
String get wireString { /* switch → 'pcm', 'wav', … */ }
}
AudioFormat? audioFormatFromWireString(String value) { /* … */ }
extension STTConfigurationConvenience on STTConfiguration {
static STTConfiguration defaults() {
final r = STTConfiguration();
r.language = STTLanguage.STT_LANGUAGE_EN;
r.sampleRate = 16000;
// …
return r;
}
}
TypeScript(ts-proto)——ts-proto 的消息是 interface 而非 class,不能有 static 方法,所以生成命名空间化的自由函数,且遵循 ts-proto 的"首字符小写"规则(STTLanguage → sTTLanguage、RAGConfiguration → rAGConfiguration):
export const audioFormatWireString = (e: AudioFormat): string => { /* … */ };
export const audioFormatFromWireString = (s: string): AudioFormat | undefined => { /* … */ };
export const sttConfigurationDefaults = (): STTConfiguration => ({
modelId: '',
language: STTLanguage.STT_LANGUAGE_EN,
sampleRate: 16000,
// … 每个非可选字段都必须出现在字面量里
});
export const validateSTTConfiguration = (m: STTConfiguration): void => {
if (m.sampleRate < 8000 || m.sampleRate > 48000) {
throw new ValidationError(`sample_rate must be in 8000...48000 (got ${m.sampleRate})`);
}
};
TS 接口字面量要求每个非可选字段都出现,所以 defaults() 必须把未注解字段也初始化为 proto3 零值("" / 0 / false / <a href="https://link.gitcode.com/i/4d941def8c91ad6bd3be34437bffc105" target="_blank">] / 枚举的 _UNSPECIFIED);useOptionals=messages 标志([idl/codegen/generate_ts.sh 传入)只让嵌套 message 字段变为 ? 可选。
3.4 默认字面量翻译矩阵
设计文档给出了每种 proto 类型在各语言下的默认值字面量翻译规则(来自 _convenience_common.py.to_default_literal):
| Proto 类型 | Swift | Kotlin | Dart | TypeScript |
|---|---|---|---|---|
string "auto" |
"auto" |
"auto" |
'auto' |
'auto' |
bool "true" |
true |
true |
true |
true |
int32 "3" |
3 |
3 |
3 |
3 |
int64 "16000" |
16000 |
16000L |
Int64(16000) |
Long.fromNumber(16000) |
float "0.7" |
0.7 |
0.7f |
0.7 |
0.7 |
double "0.7" |
0.7 |
0.7 |
0.7 |
0.7 |
enum STT_LANGUAGE_EN |
.en |
STTLanguage.STT_LANGUAGE_EN |
STTLanguage.STT_LANGUAGE_EN |
STTLanguage.STT_LANGUAGE_EN |
四个生成器共享同一个 idl/codegen/_convenience_common.py 模块(descriptor 解析、wire-format 读取、命名转换、默认字面量翻译都在这里),这也是 CI 漂移门禁能一次锁定四份输出字节一致的前提。
4. SCHEMA_LOCK 与 semver 门禁:漂移信号从哪来
既然绑定不提交、git status 无法检测"忘了跑 codegen",就必须有一个被跟踪的文件在 Schema 变化时跟着变化。这个文件就是 idl/SCHEMA_LOCK。
4.1 SCHEMA_LOCK 是什么
从 idl/codegen/schema_lock.sh 的实现可以完整还原它的构造:
- 摘要算法:对每个
idl/*.proto,按LC_ALL=C的 basename 顺序拼接"<basename>\n<sha256-of-contents>\n",再整体做一次 sha256——只含 basename,所以无论仓库 checkout 在哪个路径,摘要值都相同; - 内容:
IDL_VERSION(来自idl/VERSION)、IDL_PROTO_COUNT(proto 文件数)、IDL_SCHEMA_SHA256(上述摘要)、IDL_PROTOC_VERSION(来自core/VERSIONS::PROTOC_VERSION,因为 protoc 会把自身版本号戳进 C++ 头文件和每个 ts-proto 文件,protoc 钉住版本变了就意味着所有绑定必须同一 commit 重新生成); - 写入时机:
generate_all.sh每次运行都会刷新它(schema_lock.sh --update)。
4.2 三种检查模式
./idl/codegen/schema_lock.sh --print # 这个 checkout 用的是哪份 IDL?
./idl/codegen/schema_lock.sh --check # 锁是否过期(摘要与当前 .proto 不符即失败)
./idl/codegen/schema_lock.sh --check --require-bump <base-lock>
# 再检查:摘要相对 base 移动了而 VERSION 没动 → 失败
--require-bump 分支的核心逻辑(来自 idl/codegen/schema_lock.sh):若 base 摘要 ≠ 当前摘要、但 base 版本 = 当前版本,则报错".proto 表面变了但 idl/VERSION 还是 X",并提示按 patch/minor/major 规则 bump 后重跑 generate_all.sh。它还有一层防护:base 路径不存在时报错而不是静默跳过(&& [ -f ... ] 的旧写法会让整个 bump 检查在 typo 或 git show 失败时消失)。
4.3 CI 的"先生成、后验证"哲学
CI 工作流 idl-drift-check.yml 的姿势是 generate, then verify——而不是"重新生成再 diff",因为对 gitignore 的文件做 diff 永远不会失败。整体流程:
generate_all.sh重新生成全部语言绑定并刷新SCHEMA_LOCK;ci-drift-check.sh(git diff --exit-code)验证:改 Schema 没跑 codegen → 锁过期 → 失败;改 Schema 没 bump VERSION → 失败;- 便捷生成器黄金测试(见第 7 节)在 drift check 之前运行。
配套的 docs/reference/generated-code-contract.md 还给出了两个日常查询命令:
./idl/codegen/schema_lock.sh --print # which IDL is this checkout?
./idl/codegen/ci-drift-check.sh # the whole gate, exactly as CI runs it
5. buf.yaml:手工的 lint 与 breaking 检查(CI 不用它)
idl/buf.yaml 配置了 buf 的两个能力,但没有接入任何 CI 工作流——本仓库的 Schema 兼容性强制手段是 SCHEMA_LOCK + drift check,而不是 buf。它在"拿不准某个编辑是否 wire-breaking"时手工使用:
version: v2
modules:
- path: .
name: buf.build/runanywhere/idl
lint:
use:
- STANDARD
except:
- PACKAGE_VERSION_SUFFIX
- PACKAGE_DIRECTORY_MATCH
breaking:
use:
- WIRE
except:
- EXTENSION_NO_DELETE
- FIELD_SAME_DEFAULT
配置背后的两个设计决策:
- lint 关闭
PACKAGE_VERSION_SUFFIX和PACKAGE_DIRECTORY_MATCH——本仓库不做包版本后缀(runanywhere.v1没有runanywhere.v1.v1之类),也不把runanywhere.v1包镜像成匹配的目录树; - breaking 使用 WIRE 类别(针对 C-ABI/事件/manifest 的 wire 形态),并关闭
EXTENSION_NO_DELETE与FIELD_SAME_DEFAULT。
手工运行命令:
buf lint idl
buf breaking idl --against '.git#branch=main,subdir=idl'
6. codegen/ 里 generate_all.sh 之外的东西
docs/reference/generated-code-contract.md 覆盖了工具链引导(bootstrap_protoc.sh / bootstrap_wire.sh / bootstrap_pyproto.sh,分别按 core/VERSIONS::PROTOC_VERSION / WIRE_VERSION / PYTHON_PROTOBUF_VERSION 钉版本,并按 idl/codegen/protoc.sha256 / wire.sha256 校验和验证;下载产物缓存于 ${XDG_CACHE_HOME:-~/.cache}/runanywhere/,校验和不匹配即硬失败;RAC_PROTOC / RAC_WIRE_COMPILER / RAC_PYTHON 可覆盖,RAC_PROTOC_NO_DOWNLOAD=1 等让气隙主机大声失败而不是联网)。idl/AGENTS.md 补充的是 codegen/ 中超出 generate_all.sh 的四块内容:
6.1 便捷生成器与黄金文件回归套件
generate_{swift,kotlin,dart,ts}_convenience.py 在第 3 节已详述。它们的回归套件在 idl/codegen/tests/test_convenience_generators.py:
- 夹具 proto(idl/codegen/tests/fixtures/test_options.proto)故意覆盖每一个
rac_*注解 × 每一种标量类型(STRING/INT32/INT64/FLOAT/DOUBLE/BOOL/ENUM/MESSAGE)加上枚举上的rac_display_name/rac_analytics_key/rac_wire_string——因为生产 IDL 只实际用了rac_required(一个 string 字段)和rac_min/rac_max(int32 sample-rate 字段),int64/float/double/bool/enum-default 代码路径在生产里是休眠的,只有夹具能点亮它们; - 运行器把夹具 + 权威
rac_options.proto沙盒化,逐个调用四个生成器,并把输出与提交的黄金文件(idl/codegen/tests/golden/ 下的swift.expected/kotlin.expected/dart.expected/ts.expected)逐字节 diff; - 本地运行:
python3 idl/codegen/tests/test_convenience_generators.py(生成器或夹具改动后加--update-golden重刷黄金); - 退出码:
0全部匹配、1有生成器偏离黄金、2工具链缺失(protoc / Python protobuf); - CI 集成:作为
idl-drift-check.yml的 Convenience generator smoke test 步骤、在 drift check 之前运行。它补上了 drift check 抓不到的一类 bug——drift check 只能发现"忘了重新生成",抓不住"生成器稳定地输出错误结果"; - 无 Python 时的手工回退流程(idl/codegen/tests/README.md):把
fixtures/test_options.proto复制成_test_options.proto放进idl/→ 逐个跑generate_*_convenience.py→ 检查各语言目标文件里的预期片段(Swift 的RARacTestMode.displayName、Kotlin 的RacTestConfig.Companion.defaults()、Dart 的extension RacTestModeConvenience、TS 的racTestConfigDefaults())→ 删除复制文件并重跑generate_all.sh恢复权威输出。
6.2 swift-modality-abi.yaml:dlsym 符号表清单
idl/codegen/swift-modality-abi.yaml 是一个清单文件(不是 proto),驱动 idl/codegen/generate_swift_modality_abi.py 渲染 CppBridge+ModalityProtoABI.swift——即把每个 rac_* C 符号映射到其请求/响应 proto 类型与调用类型(invoke、stream、getWithContext、…)的 dlsym 表。规则:某个 modality 新增或改变 C 入口点时,编辑 YAML,而不是生成的 Swift;文件头部注释详细记录了每个 kind 值的 wire 形态。
6.3 跨切面的默认值后处理器
- idl/codegen/generate_cpp_defaults.py:每次
generate_all.sh无论--only子集都运行,发射对外发布的公共头文件rac_defaults_generated.h; - idl/codegen/generate_defaults_pool.py:一次性为全部七个按语言目标发射
RADefaultsPool默认值常量——它的输出与--only子集无关,无论触发哪个子集都写同样的字节。
6.4 templates/ts_async_iterable.njk:共享的流封装模板
idl/codegen/templates/ts_async_iterable.njk 是 generate_streams.sh 渲染 TypeScript AsyncIterable 流封装所用的 Nunjucks 模板,为每一种 server-streaming RPC 形状生成共享封装,被 React Native 与 Web SDK 通过 @runanywhere/proto-ts 共同消费。其驱动脚本 idl/codegen/generate_streams.sh 用一组 (service, rpc, request, response, module) 元组驱动渲染,并刻意从 request module 推导 source proto 路径(避免硬编码导致 vlm_service.proto 这类命名特例出错)。
7. 生成代码契约与下游 kit(RCLI)
docs/reference/generated-code-contract.md 还规定了一个重要的下游消费模型:C++ desktop kit(package-cpp-desktop)是另一个仓库(RCLI)不跑 protoc 就能跟上本 Schema 的方式:
idl/*.proto是唯一 Schema;- commons 只编译一次,
.pb.cc住在librac_commons.a里; - kit 随附匹配的
include/runanywhere/proto/*.pb.h、vendored 的 protobuf/absl 头文件,以及一份idl/SCHEMA_LOCK拷贝; - RCLI 用
find_package(RunAnywhere)消费这些头文件,并在cmake/sdk-pin.cmake钉住IDL_SCHEMA_SHA256——kit 的锁不匹配就是 configure 错误。
结论很明确:不要在 RCLI 里新增第二条 codegen 路径。Schema 移动时,切一个新 SDK kit 并 bump RCLI 的 pin 即可。这从机制上保证了"Schema 根只有一个、生成路径只有一条、锁是唯一的真相来源"。
8. 编写 .proto 时的检查清单
把 idl/AGENTS.md 与相关文档的核心规则汇总成一份可执行的清单:
- 样板完整性:新文件复制
pipeline.proto的 option 块,八个选项一个都不能少(缺一个 = 对应语言静默拿到错误命名); - 版本语义:注释/文档 → patch;新增字段/消息/枚举值 → minor;wire 破坏 → major。改 Schema 必须 bump
idl/VERSION; - 跑生成器:
./idl/codegen/generate_all.sh,让它刷新SCHEMA_LOCK;本地没有某语言工具链时用--skip-dart或--only; - 字段号纪律:wire 稳定或持久化消息(
ModelInfo、sdk_events.proto全部、errors.proto)绝不重编号/复用;删字段进reserved,绝不留洞复用;errors.proto数值与core/include/rac/core/rac_error.h逐一对应; service节制:仓库内无 gRPC 传输,新增service只会产生无人调用的 stub——除非你真的在描述逻辑契约(如ToolCalling);- 注解与校验边界:
rac_*注解只驱动生成的便捷 API;validate()需显式调用,commons 独立校验,二者互不替代; - 拿不准 wire 破坏性时:手工跑
buf lint idl与buf breaking idl --against '.git#branch=main,subdir=idl'自检(CI 不跑 buf,最终裁判是SCHEMA_LOCK+ drift check); - 改生成器时:跑
python3 idl/codegen/tests/test_convenience_generators.py,必要时--update-golden重刷黄金,确保四语言输出字节一致。
9. 延伸阅读
- idl/AGENTS.md —— 本文所依据的权威编写规范
- docs/reference/generated-code-contract.md —— "什么生成什么、何时生成"的完整契约与工具链固定策略
- idl/codegen/CONVENIENCE_CODEGEN_DESIGN.md —— 便捷生成器的完整设计与四语言输出形状
- idl/codegen/tests/README.md —— 便捷生成器测试的退出码与手工回退流程
- idl/rac_options.proto —— 九个
rac_*注解的权威定义 - idl/codegen/schema_lock.sh ——
SCHEMA_LOCK的构造、检查与 bump 逻辑 - idl/pipeline.proto —— option 块样板来源与注释风格范例
- idl/tool_calling.proto —— 唯一带
service块的逻辑契约范例 - 根 AGENTS.md —— 仓库级架构约定(C ABI、插件注册表、版本管理)