Repository navigation
refactor(desktop): generate Rust models from OpenAPI - #2113
Conversation
| export type AccessControlMode = "disabled" | "enforced"; | ||
| export type AccessMeResponse = { principal: AccessPrincipal, mode: AccessControlMode, resource_kinds: Array<AccessResourceType>, provider_capabilities: AccessProviderCapabilities, artifact_families: Array<ArtifactFamilyAccessCapability>, }; | ||
| export type AccessPrincipal = { type: string, id: string, description?: string | null, }; | ||
| export type AccessPrincipal = { type: Type, id: string, description?: string | null | undefined, }; |
There was a problem hiding this comment.
[P1] 提交的生成物引用了从未声明的枚举类型,pnpm typecheck 在该 head 上直接失败
AccessPrincipal.type 现在是 Type,但整份 ipc.ts 没有 export type Type。CI 在同一 head(d46395e1)给出 13 条同类错误,job 118487486779(desktop 工作流 windows,2026-10-20T06:27:37Z 失败):
ui/src/generated/ipc.ts(52,39): error TS2304: Cannot find name 'Type'.
(56,93) OutputKind (56,113) Effect (57,94) ShareUnit (82,34) MatchedBy
(123,8) Role (127,8) State (140,8) Stage (149,9) ExtractionObservationStatus
(162,16) Configuration (167,42) PromptCapabilityStatus (167,74) Reason (167,159) BuiltinProfile
原因是生成链只导出了顶层 component schema 的声明:
desktop/scripts/generate.mjs用--global-property models=<schemaNames>生成整份 wire 模型;内联 enum(AccessPrincipal.type、ArtifactRevision.state、ArtifactDreamingOperationCapability.effect等)被 OpenAPI Generator 提升为独立 Rust 类型(wire.rs的pub enum Type/Role/State…)。wire.rs::declarations()只遍历schemaNames,examples/export_ipc.rs:77把该列表.chain(...)进ipc.ts,于是这些名字被字段引用却从未输出声明。- 改动前的手写映射把这些字段降级为
string,所以旧类型自洽;换成生成模型后这条依赖被打破。
影响:windows job 停在 pnpm typecheck,其后的 pnpm test、pnpm build、cargo fmt/clippy/test 全部被跳过,因此本 head 的 Rust 侧编译与测试没有 CI 证据;PR 正文的验证清单里也没有 pnpm typecheck(pnpm build 虽然内含 tsc --noEmit,但它排在失败的步骤之后,未被执行)。
建议:让 declarations() 覆盖生成模型里所有独立声明的类型(按模块导出这些 enum 的 <T as TS>::decl,或在模板里对这类字段用 #[ts(inline)] 而非裸类型名),并在生成检查中补一步 tsc --noEmit,避免"生成物与 OpenAPI 一致但不可编译"这种情况静默通过。
There was a problem hiding this comment.
Thanks for catching this. Addressed in 32ced08 and included in the current head 08b78d4.
wire.rs::declarations() now exports all generated Rust model declarations, including the promoted inline enums, instead of only the top-level OpenAPI schemas. As a result, Type,OutputKind, Effect, ShareUnit, Role, State, Stage, and the other referenced types are now emitted into desktop/ui/src/generated/ipc.ts.
The generated IPC file no longer contains unresolved type references, and pnpm typecheck passes. The Windows validation for the current head has also passed in my fork; the official PR check is now pending/running.
There was a problem hiding this comment.
确认已在 head 08b78d48 修复。
desktop/ui/src/generated/ipc.tsblob 由f844532e(d46395e1)变为7bda98b3(08b78d48),80 个export type声明中已包含此前全部 13 个悬空类型,包括末段的export type Type = "user" | "service"。- 结构差分(
wire.rs声明集):base 39 个 → head 53 个,新增 14 个 inline enum 提升类型(Type/Role/State/Stage/Effect/ShareUnit/MatchedBy/OutputKind/Reason/Configuration/BuiltinProfile/PromptCapabilityStatus/ExtractionObservationStatus/Location),base 有而 head 缺失的类型为 0。declarations()改为从生成的 Rust 源码回扫pub (struct|enum),与generate.mjs第 194 行的顶层await generateRustModels()一致。 - CI
Desktop validation / windows(job118638863265)在该 head 上pnpm typecheck(tsc --noEmit)无输出、pnpm build内的tsc --noEmit通过、pnpm test32 passed、cargo clippy --all-targets -- -D warnings通过、pnpm ipc:check通过,此前失败的步骤之后不再被 skip。
残留一处保护范围限定,不影响当前结论:desktop/src-tauri/examples/export_ipc.rs 新增的空类型断言是 !output.contains("?: |"),只覆盖 optional + nullable 形态。required + nullable 的字段生成失败时是 ": |",不含 ?,该断言不会命中。当前契约与生成物中不存在该形态(wire.rs 内 13 处 #[ts(type = "... | null")] 均带具体类型),故这是回归保护的覆盖边界,不是本 head 的缺陷。
There was a problem hiding this comment.
Thanks for verifying. Confirmed that all 13 promoted enum types are now exported and the Windows validation passed. The remaining assertion is only a guard coverage limitation, not a defect in this head.
| {{#vendorExtensions.isMandatory}}#[serde_as(as = "serde_with::base64::Base64")]{{/vendorExtensions.isMandatory}}{{^vendorExtensions.isMandatory}}#[serde_as(as = "{{^serdeAsDoubleOption}}Option{{/serdeAsDoubleOption}}{{#serdeAsDoubleOption}}super::DoubleOption{{/serdeAsDoubleOption}}<serde_with::base64::Base64>")]{{/vendorExtensions.isMandatory}} | ||
| {{/isByteArray}} | ||
| {{!-- a mapped child's discriminator is the union's consumed serde tag: default it, skip it while unset --}} | ||
| {{#isMap}}#[ts(type = "Record<string, unknown>")] |
There was a problem hiding this comment.
[P2] #[ts(type = ...)] 覆盖掉 ts-rs 的可选性与引用类型,生成物里出现空类型并丢失 nullability
同一 head(d46395e1)有两处可直接对照的输出:
ui/src/generated/ipc.ts:68生成出created_by?: | null | undefined,。created_by在契约里是allOf: [$ref AccessPrincipal]+ nullable,模板这一行把isEnum/isString/isInteger/isNumber/isBoolean/isDateTime全部落空,拼出" | null | undefined",引用类型整段丢失。该写法在 TS 里合法(联合类型允许前导|),所以tsc不会报错,属性实际不可用。Capabilities.prompts由基线的prompts?: { [key in string]: PromptCapability } | null变成prompts: Record<string, unknown>:{{#isMap}}的注解把可选性与| null一并覆盖,而 Rust 侧仍是Option<HashMap<String, PromptCapability>>+skip_serializing_if = "Option::is_none"。TS 声明为必填非空,服务端省略该字段时前端没有类型保护,PromptCapability的值类型也一并丢失。
当前 desktop/ui/src 没有消费这两处字段,因此是类型契约上的潜在缺口,不是现行故障;grep -n "?: |" desktop/ui/src/generated/ipc.ts 只有 1 处,isMap 注解在 wire.rs 中 3 处。
建议:对 isMap/nullable 字段改为保留真实类型(例如 #[ts(type = "Record<string, PromptCapability> | null")])或只加 #[ts(optional)] 而不用 type = 覆盖;并在生成后用一条断言检查不存在 ?: | 形态的空类型,避免模板回归再次静默通过。
There was a problem hiding this comment.
Thanks for catching this. Addressed in the current head 08b78d4.
The isMap template no longer overrides the generated type with #[ts(type = "Record<string, unknown>")]. It now preserves the field’s real value type and optionality through #[ts(optional)].
After regenerating the IPC declarations:
- created_by is generated as created_by?: AccessPrincipal | null
- prompts is generated as prompts?: { [key in string]:PromptCapability }
- no empty declarations such as ?: | null | undefined remain
- the PromptCapability value type is preserved instead of becoming unknown
The OpenAPI schema declares prompts as optional but not nullable,so the generated type correctly does not include | null. pnpm typecheck and the Windows validation both pass for the current head.
There was a problem hiding this comment.
确认已在 head 08b78d48 修复,且修复方式与契约一致。
desktop/scripts/openapi-generator/rust/model.mustache第 154 行现在只在{{#isMap}}{{#isFreeFormObject}}分支发#[ts(type = "Record<string, unknown>")],有 value 类型的 map 交给 ts-rs 自行推导;{{^required}}走#[ts(optional)],{{#isNullable}}单独发#[ts(type = "... | null")]。- 两处此前指出的输出已符合预期:
ipc.ts:68为created_by?: AccessPrincipal | null;ipc.ts:86为prompts?: { [key in string]: PromptCapability }。 - 关于
prompts不再带| null:契约Capabilities.prompts(openapi/powercontext.yaml:6966)只有type: object+default: {}+additionalProperties,没有nullable,原| null是旧生成器的过度声明。这一点用未经本 PR 修改的desktop/ui/src/generated/api.d.ts(openapi-typescript从同一契约生成)交叉核对:26 个同名 schema 逐字段比对 optional/null 语义,0 处差异。 - 空类型已在两处归零:
grep -E '\?:\s*\|' ipc.ts与grep -E '#\[ts\(type = "\s*\|' wire.rs均 0 命中。
There was a problem hiding this comment.
Thanks for rechecking. Confirmed that optionality, nullability, and map value types are now preserved, and the generated output contains no empty types.
hidb4ai
left a comment
There was a problem hiding this comment.
Reviewed 08b78d48. The terminal-page assertion now correctly uses second.next_cursor.flatten().is_none(). I verified against the generated models that both an omitted cursor and explicit null are treated as terminal, while a real cursor is not. This addresses the pagination acceptance failure observed at 32ced081. The earlier enum-export and created_by/prompts type issues are also addressed; I found no remaining blocking correctness issue in the reviewed changes.
Windows Desktop validation for this head is still pending, so successful Windows and installer acceptance has not yet been established for this commit.
| ## Contracts and resources | ||
|
|
||
| `pnpm --dir desktop generate` derives TypeScript schemas and the reviewed ten-operation manifest from `openapi/powercontext.yaml`; `generate:check` detects drift, including a normalized contract SHA-256. Typed Rust adapters consume that generated manifest and generated wire schemas. No public route is independently handwritten in the application. The manifest is not an IPC permission grant. | ||
| `pnpm --dir desktop generate` derives TypeScript schemas, the reviewed ten-operation manifest, and the Rust wire models from `openapi/powercontext.yaml`; `generate:check` detects drift, including a normalized contract SHA-256. The OpenAPI Generator CLI version is pinned in `desktop/openapitools.json`, and the limited Rust customization lives in `desktop/scripts/openapi-generator/rust/`. Generated `src-tauri/src/transport/wire.rs` must not be edited by hand: after changing the contract or generator template, run `pnpm --dir desktop generate`, then run `pnpm --dir desktop generate:check` in CI. Typed Rust adapters consume that generated manifest and generated wire schemas. No public route is independently handwritten in the application. The manifest is not an IPC permission grant. |
There was a problem hiding this comment.
[P2] 新增的生成链前置(PATH 上的 java、首次运行下载 JAR)未写入两份 README 的构建前置
本 PR 把 generate:check 的代价从纯 Node 计算变成「启动 JVM + 可能下载 JAR + 调用 rustfmt」,但 desktop/README.md 第 20 行的前置清单与 README.zh.md 对应位置仍只有 VS C++ Build Tools、Node 24.14.1、pnpm 11.13.1、Rust 1.95.0、WebView2。
证据链:
desktop/scripts/generate.mjs第 194 行是顶层const rustModels = await generateRustModels();,--check分支在第 226 行才开始比对字节 ⇒generate:check同样执行生成器,不是只读校验。generateRustModels()内先pnpm exec openapi-generator-cli generate ...,再run("rustfmt", ["--edition", "2024", wirePath])。desktop/package.json的lint与build都串了generate:check;CI 日志(job118638863265)可对照:第 460 行$ oxlint ui scripts && pnpm generate:check && ...,第 527 行$ pnpm generate:check && tsc --noEmit && vite build。即pnpm lint/pnpm build两个常规命令都依赖该链路。- 该 CLI 的官方要求:「The OpenAPI Generator is a Java project.
openapi-generator-cliwill download the appropriate JAR file and invoke thejavaexecutable to run the OpenAPI Generator. You must have thejavabinary executable available on yourPATHfor this to work. (JDK 11 is the minimal version supported.)」(openapitools/openapi-generator-cli) - CI 通过不能证明本地可复现:
windows-2025runner 的 PATH 已预装C:/hostedtoolcache/windows/Java_Temurin-Hotspot_jdk/17.0.20-101/x64/bin(同一 job 日志的环境 PATH 行),仓库未声明这一前置。JAR 在首次运行时从 Maven Central/Sonatype 拉取,无哈希校验,openapitools.json只固定版本串。
影响:缺 JDK 或无法访问 Maven 的贡献者执行 pnpm --dir desktop lint / build 会在 generate:check 阶段失败,而 README 的构建前置不会指向原因。
建议:在 desktop/README.md 的前置段落与其 README.zh.md 对应段落同时补上 JDK 11+ 与「首次 generate 需访问 Maven Central」;若希望离线可复现,在 openapitools.json 里加 storageDir 或说明预置 JAR 的位置。无需改动产品运行时行为。
There was a problem hiding this comment.
Addressed in the latest update.
Both desktop/README.md and desktop/README.zh.md now document:
- JDK 11 or newer
- java must be available on PATH
- generate and generate:check invoke the Java-based OpenAPI Generator
- the first run may download the pinned Generator JAR from Maven Central and therefore requires network access
I also verified the updated setup with pnpm install --frozen-lockfile, pnpm generate:check, and pnpm typecheck.
There was a problem hiding this comment.
确认已在 head e7ce9f38 修复,两份文档均已落实且语义一致。
改动核实(提交 6f5d5074,desktop/README.md +3/-1、desktop/README.zh.md +3/-1):
README.md第 20 行前置清单已含JDK 11 or newer with `java` available on `PATH`;README.zh.md第 20 行为「JDK 11 或更高版本(确保java位于PATH中)」,与英文版逐项对应。- 两份均新增一段说明:
generate/generate:check启动基于 Java 的 OpenAPI Generator,首次运行可能从 Maven Central 下载固定版本 JAR、因此首次生成或漂移检查需网络,后续可复用。英文用may download、中文用「可能……下载」,措辞与「已有缓存时不下载」的实际行为一致,未过度声明。 - 「JDK 11」这一下限对
openapitools.json固定的 Generator 7.26.0 准确:上游 README 写明Java 11 runtime at a minimum,安装前置列* [Java 11];openapitools.json的version为7.26.0,未随本次合并漂移。
声称的验证已由 CI 独立证实(Desktop validation / windows,run 39590150278,job 119188793670,head_sha=e7ce9f38,conclusion success):
pnpm install --frozen-lockfile通过(说明 merge 解决的pnpm-lock.yaml冲突与package.json自洽)pnpm lint输出$ node scripts/generate.mjs --check→Desktop contract matches OpenAPI.pnpm typecheck(tsc --noEmit)、pnpm test(vitest)、pnpm build(含generate:check+tsc+vite build)、cargo fmt --check、cargo clippy --all-targets -- -D warnings、cargo test、pnpm ipc:check全部通过,且pnpm tauri build --bundles nsis完成
一处可选改进,不阻塞合并:desktop/.mise.toml 目前只 pin 了 node 与 pnpm,未包含 java。用 mise 的贡献者仍需自行装 JDK,mise 无法一键就绪。若希望与「Node/pnpm 版本由 .mise.toml 固定」的原则对齐,可考虑加一行 Java(例如 java = "temurin-21"),同时保持 README 的 11+ 下限不变。
该线程可以关闭。
AlexStocks
left a comment
There was a problem hiding this comment.
复审结论(head e7ce9f38,含 master 合并)
上一轮 3 条 Finding 全部闭合,本轮未发现新增阻断项。
已闭合
| Finding | 验证方式 | 结果 |
|---|---|---|
P1 ipc.ts 引用未声明的提升枚举 |
80 个 export type 声明覆盖全部引用(仅剩泛型参数 T) |
闭合 |
P2 模板 #[ts(type)] 覆盖 optional/nullable |
created_by?: AccessPrincipal | null、prompts?: { [key in string]: PromptCapability };空类型 0 处 |
闭合 |
| P2 README 未记录 Java 生成前置 | 两份 README 均补 JDK 11+/PATH/首次下载 JAR | 闭合 |
合并 master 的正确性
本次 e7ce9f38 合并了 master(88899876)。上游把 HostDiagnostic.presence 改名为 status(diagnostics.rs blob 95740d75→2a998ec8),这份 PR 随之重新生成了 ipc.ts(blob 7bda98b3→7474c293):
ipc.ts:25为export type HostDiagnostic = { host: string, status: string, checks: Array<DiagnosticItem>, };,与diagnostics.rs:60-64一致- 消费方
Diagnostics.tsx:150已用host.status wire.rsblob 未变(73828fce)是正确现象:HostDiagnostic由export_ipc.rs从diagnostics模块导出,不走wire::declarations()
这正好实证了上一轮 P1 修复的价值——合并引入上游字段重命名时,generate:check / ipc:check 的漂移保护捕获到了差异,生成物没有静默过期。
CI
e7ce9f38 上 43 个 check run 全部 success(installed-package SKIPPED 属设计,仅 workflow_dispatch 触发)。Desktop validation / windows(run 39590150278)中 generate:check 输出 Desktop contract matches OpenAPI.,typecheck/test/build/cargo fmt/clippy -D warnings/cargo test/ipc:check/tauri build --bundles nsis 均通过。
遗留观察(非阻断)
export_ipc.rs的空类型断言!output.contains("?: |")只覆盖optional + nullable形态;required + nullable失败时产生": |"(无?),不会被命中。当前契约无该形态,属保护范围边界。ipc:check输出 30 条ts-rs failed to parse serde attribute(deserialize_with = "Option::deserialize"),两版 head 数量相同,属既有现象;对应 3 处字段均带显式#[ts(type = "... | null")]覆盖,不影响生成结果。
合并门禁
mergeable: MERGEABLE、mergeStateStatus: BLOCKED、reviewDecision: REVIEW_REQUIRED。按 ruleset 9930477 需 2 个 approval,当前 1 个(hidb4ai)⇒ 还缺 1 个,与代码质量无关。
本结论基于 exact head 的 CI 日志与提交树静态核对;本地未执行 pnpm / cargo(本机缺少 pnpm 与可用的 registry/工具链),原因在此标注。
Which issue or RFC does this PR close?
Closes #1896
Rationale for this change
Desktop previously used a custom hand-written OpenAPI-to-Rust schema mapper. This could drift from the OpenAPI contract and required manual updates when request or response schemas changed.
This PR replaces that mapping with a pinned OpenAPI Generator while preserving the existing Desktop transport and security boundary.
What changes are included in this PR?
Are there any user-facing changes?
No direct user-facing behavior changes are intended.
This is an internal Desktop contract and build refactor. The existing HTTP API, transport security policy, IPC boundary, persisted profile format, and memory workflow remain unchanged.
How was this change tested?
The following checks passed on Linux:
The frontend test suite reports 32 passing tests. Cross-platform package validation remains covered by CI.
AI usage statement
AI tools were used during development. OpenAI Codex, powered by GPT-5, was used to inspect the existing Desktop/OpenAPI implementation, propose and implement the generator integration, resolve generated-type compatibility issues, review the changes, and run validation commands. The final implementation and test results were reviewed locally by the contributor.