RunAnywhere IDL 指南:39 个 Proto 文件驱动的跨平台 SDK 代码生成与 Schema 演化规范

原创2026-09-24 22:07:371,681 阅读
文章标签:AI模型推理服务推理引擎本地部署多模态

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/*.proto is the schema root; idl/codegen/generate_all.sh emits 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 永远不会失败。整体流程:

  1. generate_all.sh 重新生成全部语言绑定并刷新 SCHEMA_LOCK;
  2. ci-drift-check.sh(git diff --exit-code)验证:改 Schema 没跑 codegen → 锁过期 → 失败;改 Schema 没 bump VERSION → 失败;
  3. 便捷生成器黄金测试(见第 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 的方式:

  1. idl/*.proto 是唯一 Schema;
  2. commons 只编译一次,.pb.cc 住在 librac_commons.a 里;
  3. kit 随附匹配的 include/runanywhere/proto/*.pb.h、vendored 的 protobuf/absl 头文件,以及一份 idl/SCHEMA_LOCK 拷贝;
  4. 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 与相关文档的核心规则汇总成一份可执行的清单:

  1. 样板完整性:新文件复制 pipeline.proto 的 option 块,八个选项一个都不能少(缺一个 = 对应语言静默拿到错误命名);
  2. 版本语义:注释/文档 → patch;新增字段/消息/枚举值 → minor;wire 破坏 → major。改 Schema 必须 bump idl/VERSION;
  3. 跑生成器:./idl/codegen/generate_all.sh,让它刷新 SCHEMA_LOCK;本地没有某语言工具链时用 --skip-dart 或 --only;
  4. 字段号纪律:wire 稳定或持久化消息(ModelInfo、sdk_events.proto 全部、errors.proto)绝不重编号/复用;删字段进 reserved,绝不留洞复用;errors.proto 数值与 core/include/rac/core/rac_error.h 逐一对应;
  5. service 节制:仓库内无 gRPC 传输,新增 service 只会产生无人调用的 stub——除非你真的在描述逻辑契约(如 ToolCalling);
  6. 注解与校验边界:rac_* 注解只驱动生成的便捷 API;validate() 需显式调用,commons 独立校验,二者互不替代;
  7. 拿不准 wire 破坏性时:手工跑 buf lint idl 与 buf breaking idl --against '.git#branch=main,subdir=idl' 自检(CI 不跑 buf,最终裁判是 SCHEMA_LOCK + drift check);
  8. 改生成器时:跑 python3 idl/codegen/tests/test_convenience_generators.py,必要时 --update-golden 重刷黄金,确保四语言输出字节一致。

9. 延伸阅读

登录后查看全文
runanywhere-sdks