diff --git a/.eslintrc.cjs b/.eslintrc.cjs index cf2f89177..ae8275e7c 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -164,6 +164,10 @@ module.exports = { 'server-rs/target-*', 'apps/desktop-shell/src-tauri/target', 'apps/ai-game-creator-shell/src/features/ui-editor/types/**', + // ts-rs 生成绑定:不接受 eslint --fix 二次改写,必须与原始输出逐字节一致 + 'packages/shared/src/contracts/generated/**', + 'apps/ai-game-creator-shell/src/view/project-development/chat/generated/**', + 'apps/ai-game-creator-shell/src/services/generated/**', 'apps/ai-game-creator-shell/src/features/project-workspace/generated/**', 'target', 'src/main.tsx', diff --git a/.gitattributes b/.gitattributes index 572a7e25f..a63eef88a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -24,5 +24,10 @@ *.anim text *.controller text -# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。 -packages/shared/src/contracts/generated/** linguist-generated=true +# ts-rs 生成绑定:保留在仓库中供 TS 消费,但不作为手写源文件统计。 +# ts-rs 原始输出在多行对象 / 枚举变体后带行尾空格,生成目录统一豁免行尾空白检查; +# 一致性由 `npm run check:generated-bindings` 逐字节比对原始输出来保证。 +packages/shared/src/contracts/generated/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/features/ui-editor/types/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/view/project-development/chat/generated/** linguist-generated=true whitespace=-trailing-space +apps/ai-game-creator-shell/src/services/generated/** linguist-generated=true whitespace=-trailing-space diff --git a/.prettierignore b/.prettierignore index 0239b5197..eabd38c57 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,7 +5,11 @@ node_modules # ts-rs 生成产物:必须与 Rust 声明逐字节一致,`check:generated-bindings` 会重新生成并比对。 packages/shared/src/contracts/generated/ public/Icons +# ts-rs 生成绑定:一律以原始输出提交,禁止 prettier 二次改写 +packages/shared/src/contracts/generated/ apps/ai-game-creator-shell/src/features/ui-editor/types/ +apps/ai-game-creator-shell/src/view/project-development/chat/generated/ +apps/ai-game-creator-shell/src/services/generated/ apps/ai-game-creator-shell/src-tauri/resources/agc-skills/ media *.log diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 71ea8d332..0fccd699a 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -49,6 +49,7 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminPaymentOrderListResponse, AdminProjectSnapshotChannelsResponse, AdminProjectSnapshotListQuery, AdminProjectSnapshotListResponse, @@ -825,6 +826,12 @@ export function listAdminRechargeOrders( ); } +export function listAdminPaymentOrders(token: string) { + return request('/admin/api/payment/orders', { + token, + }); +} + export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) { return request( `/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 8f96fa180..715d40d64 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1119,6 +1119,39 @@ export interface AdminWechatPaymentCheckPayload { knownRefundsRefreshed: number; } +export interface AdminPaymentOrderEntry { + orderId: string; + appId: string; + ownerUserId: string; + merchantOrderId: string; + title: string; + amountCents: number; + currency: string; + provider: string; + providerTradeNo: string | null; + status: string; + createdAt: string; + expiresAt: string; + paidAt: string | null; +} + +export interface AdminPaymentOrderListResponse { + entries: AdminPaymentOrderEntry[]; + webhooks: AdminPaymentWebhookEntry[]; +} + +export interface AdminPaymentWebhookEntry { + deliveryId: string; + orderId: string; + appId: string; + callbackUrl: string; + status: string; + attemptCount: number; + availableAt: string; + lastErrorMessage: string | null; + updatedAt: string; +} + export interface AdminRechargeRefundPreviewResponse { order: AdminRechargeOrderEntryPayload; paymentCheck: AdminWechatPaymentCheckPayload; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 8a0d9a3cb..7e7a5fe6f 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -38,6 +38,7 @@ import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminMembershipPlanPage } from '../pages/AdminMembershipPlanPage'; import { AdminOverviewPage } from '../pages/AdminOverviewPage'; +import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage'; import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage'; import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage'; import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage'; @@ -311,6 +312,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {activeRouteId === 'payment-orders' ? ( + + ) : null} {activeRouteId === 'editor-generation-pricing' ? ( void; +}; + +function statusTone(status: string): 'ok' | 'pending' | 'error' { + if (status === 'paid') return 'ok'; + if (status === 'closed' || status === 'expired') return 'error'; + return 'pending'; +} + +export function AdminPaymentOrderPage({ + token, + onUnauthorized, +}: AdminPaymentOrderPageProps) { + const [entries, setEntries] = useState([]); + const [webhooks, setWebhooks] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const load = useCallback(() => { + setIsLoading(true); + setError(''); + void listAdminPaymentOrders(token) + .then((response) => { + setEntries(response.entries ?? []); + setWebhooks(response.webhooks ?? []); + }) + .catch((nextError: unknown) => { + handlePageError(nextError, onUnauthorized, (message) => + setError(message || formatAdminApiError(nextError)), + ); + }) + .finally(() => setIsLoading(false)); + }, [onUnauthorized, token]); + + useEffect(() => { + load(); + }, [load]); + + return ( + + + 刷新 + + } + /> + 暂无外部回调记录。} + columns={[ + { + key: 'delivery', + label: '投递', + render: (entry) => ( + <> + {entry.deliveryId} + {entry.orderId} + + ), + }, + { key: 'app', label: '应用', render: (entry) => entry.appId }, + { + key: 'callback', + label: '回调地址', + render: (entry) => entry.callbackUrl, + }, + { + key: 'status', + label: '状态', + render: (entry) => ( + + {entry.status} + + ), + }, + { + key: 'attempts', + label: '尝试次数', + render: (entry) => entry.attemptCount, + }, + { + key: 'error', + label: '最近错误', + render: (entry) => entry.lastErrorMessage ?? '-', + }, + { + key: 'updated', + label: '更新时间', + render: (entry) => entry.updatedAt, + }, + ]} + rows={webhooks} + rowKey={(entry) => entry.deliveryId} + /> + {error ? ( +
{error}
+ ) : null} + + 暂无支付订单,外部产品创建订单后会显示在这里。 + + } + columns={[ + { + key: 'order', + label: '订单', + render: (entry) => ( + <> + {entry.merchantOrderId} + {entry.orderId} + + ), + }, + { + key: 'app', + label: '应用', + render: (entry) => ( + <> + {entry.title} + {entry.appId} + + ), + }, + { + key: 'amount', + label: '金额', + render: (entry) => + `${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`, + }, + { key: 'provider', label: '渠道', render: (entry) => entry.provider }, + { + key: 'status', + label: '状态', + render: (entry) => ( + + {entry.status} + + ), + }, + { + key: 'created', + label: '创建时间', + render: (entry) => entry.createdAt, + }, + { + key: 'paid', + label: '支付时间', + render: (entry) => entry.paidAt ?? '-', + }, + ]} + rows={entries} + rowKey={(entry) => entry.orderId} + /> +
+ ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index fbe737fec..5f3f50505 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -86,7 +86,7 @@ maud = "0.27.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } [profile.dev] opt-level = 0 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json index 8ba3c986f..a544d209e 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct.json @@ -17,8 +17,14 @@ "browser.status": "整体 passed={}", "browser.notStarted": "本次未启动 Chromium", "browser.evidence": "[AGC 浏览器事实证据]\nattempt={attempt}; completionError={completion_status}; browser={browser_status}; codeFingerprintChanged={output_changed}\n{}\n诊断与平台素材运行时观察:{}\n交互探针:{}\n客户端已保存结构化证据;它是事实输入,不代表 Codex 已阅读截图或已经完成修复。当前 cwd 是项目根目录;按需读取实际文件,并自行决定是否修改、再次试玩或直接回复。AGC 只负责启动浏览器、采集证据和执行项目边界,Codex 负责解释结果。", - "browser.noFailureDetails": "无额外硬失败详情", - "browser.noVisibleControls": "未找到可执行的可见控件", + "browser.noFailureDetails": "无额外视觉检查失败详情", + "browser.noVisibleControls": "无建议性交互探针记录", + "browser.gameplayNotRun": "玩法检查:未执行(visual 仅提供视觉证据)。", + "browser.gameplayMissing": "固定场景玩法结果缺失,不能确认通过。", + "browser.gameplayNoDiagnostic": "无诊断记录", + "browser.gameplayNoUnmetAssertion": "无", + "browser.gameplayViewport": "{viewport}: gameplayPassed={passed};首条玩法诊断:{diagnostic};首个未通过断言:{assertion}", + "browser.gameplayEvidence": "[固定场景玩法证据]\n{}\n断言 passed=false 表示未通过,也可能因前序阻断而未执行;修复以具体诊断为准,不把所有未通过项视为独立故障。固定场景通过不代表完整关卡已验证;reportPath 仅为宿主证据引用,无需读取私有报告。", "system.role": "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,负责当前任务的执行。先理解用户意图:普通对话直接回答,项目请求按需要检查、修改、运行和验证,并用简洁中文报告真实结果。", "system.workspaceBoundary": "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。", "system.toolAuthorization": "AGC 工具授权:agc_tools 使用当前登录会话。工具返回 401/403 时,报告登录或权限状态异常并停止,交由用户处理登录和权限。", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md index 6db5127e8..33c8ac092 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md @@ -10,6 +10,8 @@ Canvas/WebGL probes can show whether registered image sources entered a core ren `mode=visual` captures desktop/mobile screenshots, loading, layout and diagnostics without clicking gameplay controls. It does not prove that the game loop works. `mode=gameplay` uses the fixed approved scenario (`generic-v1` by default, or the existing `tetris-v1` / `lane-defense-v1` contract), not an agent-written autopilot. It exercises the scenario independently in desktop and mobile viewports and requires both results. The receipt declares `gameplayViewports` separately from `visualViewports`. Older reports with only a desktop `playtest` result do not prove mobile gameplay. These bounded scenarios do not establish completion of an arbitrary long level. +Use each viewport's `gameplayResults` to diagnose failures: start with its first `diagnostics` entry, then inspect `assertions` and the recorded initial/final phase, sequence and level. An assertion with `passed=false` may not have run because an earlier step blocked; do not treat every unmet assertion as an independent defect. Missing viewport evidence is not a pass. In visual mode, `gameplayResults` is empty because gameplay was not run. `reportPath` is a private host evidence reference; use the returned diagnostics instead of trying to read that report. + For `generic-v1`, expose exactly one `script#playable-web-game-state[type="application/json"]` whose JSON is a projection of actual game state: ```json diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 4625c1ca2..12678da03 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.35", + "version": "2026-08-26.37", "skills": [ { "name": "agc-unity-editor", @@ -140,7 +140,7 @@ "references/browser-evidence-contract.md", "references/runner-physics.mjs" ], - "sha256": "e0e7bdca529e068ce2974a5e312a267108ab5acb20e8f9d7c0c22d049d90b796" + "sha256": "6800059c4e7bae70b0b42ec47175fc85e38d789e1759dca5df2067f715dc0657" }, { "name": "agc-client-projection", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 7a2fd4162..db9db4abf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -29,9 +29,9 @@ mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; -mod direct_turn_error; -mod direct_turn_failure; mod direct_validation; +#[cfg(test)] +pub(crate) use direct_validation::browser_tool_result; mod generation; pub(crate) mod json_sidecar; pub(crate) mod redaction; @@ -45,16 +45,17 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, direct_game_creator_claude_code_chat_at, - direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, - game_creator_claude_code_cli_version_identity, + cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, + game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; +pub(crate) use codex_app_server::turn_error::*; use codex_app_server::*; #[cfg(not(test))] pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project, - DirectTurnCancelView, + TurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -72,8 +73,6 @@ pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_error::*; -pub(crate) use direct_turn_failure::*; pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use generation::*; pub(crate) use json_sidecar::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 483f42b8e..f0f01ad0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -94,11 +94,14 @@ impl Drop for ActiveClaudeCodeTurnGuard { } } -struct ExternalMcpStopGuard; +struct ExternalMcpStopGuard { + root: PathBuf, + token: String, +} impl Drop for ExternalMcpStopGuard { fn drop(&mut self) { - stop_external_mcp_loopback(); + stop_external_mcp_loopback_for_root(&self.root, &self.token); } } @@ -375,7 +378,7 @@ async fn run_sidecar_turn( pub(crate) fn cancel_direct_claude_code_turn_at( root: &Path, client_turn_id: Option<&str>, -) -> Result, String> { +) -> Result, String> { let key = claude_project_key(root); let turns = active_claude_code_turns() .lock() @@ -394,7 +397,7 @@ pub(crate) fn cancel_direct_claude_code_turn_at( active.alive.store(false, Ordering::Release); kill_claude_code_process_tree(active.pid); let client_turn_id = active.client_turn_id.clone(); - Ok(Some(super::codex_app_server::DirectTurnCancelView { + Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), message: "已向正在运行的 cc 回合发出终止".to_string(), client_turn_id, @@ -747,6 +750,46 @@ fn parse_usage(value: &serde_json::Value) -> Option }) } +/// 把 Claude Code/sidecar 的字符串失败投影到与 Codex app-server 相同的 LlmError 分类。 +/// +/// DirectProject 不能把上游 HTTP 错误统统包装成 Transport:那会把 429、401、5xx 等可识别 +/// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / +/// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 +pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + if let Some(status_code) = claude_code_failure_status_code(&detail) { + return platform_llm::LlmError::Upstream { + status_code, + message: detail, + }; + } + if detail.contains("Claude Agent SDK sidecar 回合超时") { + return platform_llm::LlmError::Timeout { attempts: 1 }; + } + if detail.contains("Claude Code 缺少最终回复") { + return platform_llm::LlmError::EmptyResponse; + } + if detail.contains("sidecar 输出不是有效 JSON") || detail.contains("stream-json 包含无效 JSON") + { + return platform_llm::LlmError::Deserialize(detail); + } + platform_llm::LlmError::Transport(detail) +} + +fn claude_code_failure_status_code(detail: &str) -> Option { + ["Request rejected (", "HTTP "].iter().find_map(|marker| { + let tail = detail.split_once(marker)?.1; + let digits = tail + .chars() + .take_while(|character| character.is_ascii_digit()) + .collect::(); + if digits.len() != 3 { + return None; + } + let status_code = digits.parse::().ok()?; + (100..=599).contains(&status_code).then_some(status_code) + }) +} + fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, @@ -758,7 +801,7 @@ fn parse_claude_code_result( let detail = claude_result_error_detail(&value) .map(|detail| format!(":{detail}")) .unwrap_or_default(); - return Err(platform_llm::LlmError::Transport(format!( + return Err(claude_code_failure_to_llm_error(format!( "Claude Code 返回失败终态{detail}" ))); } @@ -917,12 +960,16 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( system_prompt: String, user_prompt: String, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); - let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?; + let (mcp_url, mcp_token) = + start_external_mcp_loopback_for_direct_turn(root, llm.web_search_enabled).await?; direct_turn_trace("claude-mcp-loopback-ready"); - let _mcp_guard = ExternalMcpStopGuard; + let _mcp_guard = ExternalMcpStopGuard { + root: root.to_path_buf(), + token: mcp_token.clone(), + }; let home = crate::project::resolve_local_project_path(root, ".agent/runtime/claude-code/home")?; std::fs::create_dir_all(&home) .map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?; @@ -984,25 +1031,19 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match client_turn_id { - Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), - None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) + { + Ok(item_id) => item_id, + Err(error) => { + eprintln!("[agc-cc-direct] persist assistant failed: {error}"); + direct_turn_trace("claude-parse-error"); + return Err(format!("写入本项目对话历史失败:{error}")); + } }; - let item = serde_json::json!({ - "type": "message", - "role": "assistant", - "id": item_id.clone(), - "content": [{ "type": "output_text", "text": text }], - }); - if let Err(error) = append_direct_project_history_item_at(root, &item) { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); - } // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 - let at = crate::agent::direct_now_ms(); + let at = crate::agent::now_ms(); crate::agent::append_thread_event( &crate::agent::thread_id_for_project(root), ThreadEvent::item_completed( @@ -1026,9 +1067,41 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( parsed } +fn persist_direct_claude_assistant_reply_at( + root: &Path, + client_turn_id: Option<&str>, + text: &str, +) -> Result { + let stable_item_id = match client_turn_id { + Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), + None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + }; + let mut item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": stable_item_id.clone(), + "content": [{ "type": "output_text", "text": text }], + }); + match append_direct_project_history_item_at(root, &item) { + Ok(()) => Ok(stable_item_id), + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一 client turn 的验收反馈会再次进入 cc 执行器。首个回复保留稳定身份, + // 后续不同回复必须另用 item id;否则历史层会把反馈收尾误判成失败。 + let feedback_item_id = format!("{stable_item_id}:{}", uuid::Uuid::new_v4()); + item["id"] = serde_json::Value::String(feedback_item_id.clone()); + append_direct_project_history_item_at(root, &item) + .map(|()| feedback_item_id) + .map_err(|retry_error| { + format!("反馈回复历史追加失败:{retry_error}(首个条目冲突:{error})") + }) + } + Err(error) => Err(error), + } +} + fn parse_direct_stream_result( stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { let text = std::str::from_utf8(stdout) .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; @@ -1056,7 +1129,7 @@ fn parse_direct_stream_result( .collect::(); if !visible.is_empty() { if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment(visible)); + observer(TurnObservation::AgentMessageSegment(visible)); } } } @@ -1130,6 +1203,29 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn claude_failure_reuses_codex_error_categories() { + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:API Error: Request rejected (429)".into() + ), + platform_llm::LlmError::Upstream { + status_code: 429, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件".into() + ), + platform_llm::LlmError::Timeout { attempts: 1 } + )); + assert!(matches!( + claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), + platform_llm::LlmError::EmptyResponse + )); + } + #[test] fn parses_direct_stream_result_and_final_text() { let mut observed = Vec::new(); @@ -1145,7 +1241,7 @@ mod tests { assert_eq!(text, "最终回复"); assert!(matches!( observed.as_slice(), - [DirectCodexTurnObservation::AgentMessageSegment(_)] + [TurnObservation::AgentMessageSegment(_)] )); } @@ -1160,6 +1256,28 @@ mod tests { assert!(error.contains("Authentication failed")); } + #[test] + fn direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id() { + let root = tempfile::tempdir().expect("temp dir"); + crate::init_local_game_project_at(root.path(), "cc-feedback", "cc feedback") + .expect("init project"); + let turn_id = Some("client-turn-feedback-0001"); + + let first = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复") + .expect("persist first reply"); + let identical = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "首轮回复") + .expect("an identical retry remains idempotent"); + let second = persist_direct_claude_assistant_reply_at(root.path(), turn_id, "反馈后回复") + .expect("a host feedback retry must persist a second reply"); + + assert_eq!(first, identical); + assert_ne!(first, second); + assert!(second.starts_with("direct-codex:client-turn-feedback-0001:assistant:")); + let items = + crate::agent::read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(items.len(), 2); + } + fn test_platform_session() -> crate::platform_session::PlatformSessionSnapshot { crate::platform_session::PlatformSessionSnapshot { user_id: "user-1".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 72b02c321..656783c38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1,7 +1,7 @@ //! Native / third-party approval adapter. The host execution session owns policy //! and persistence; this module only binds the app-server protocol to its leases. -use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError}; +use super::super::{direct_delivery, direct_execution, direct_validation, TurnError}; use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner}; use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession}; use serde_json::{json, Value}; @@ -152,12 +152,10 @@ pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str = impl HostOutcomeText { /// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。 - pub(super) fn into_run_result(self) -> Result { + pub(super) fn into_run_result(self) -> Result { match self { Self::Report(text) => Ok(text), - Self::RepairRequired { detail } => { - Err(super::DirectTurnRunFailure::RepairRequired { detail }) - } + Self::RepairRequired { detail } => Err(super::RunFailure::RepairRequired { detail }), } } } @@ -246,7 +244,7 @@ pub(super) struct ExecutionAdapter { outcome: watch::Sender>, /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, /// 原因与交付报告同一份文本。 - turn_failure: Mutex>, + turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, /// 已留痕的拒绝原因(`method\u{1}reason`)。同一回合内同因只记一次,避免模型重试刷屏。 @@ -921,8 +919,8 @@ impl ExecutionAdapter { /// /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 /// 不得覆盖它。 - pub(super) async fn fail_turn(&self, failure: DirectTurnError) { - let reason = failure.to_string(); + pub(super) async fn fail_turn(&self, failure: TurnError) { + let reason = failure.diagnostic_detail(); if self.is_closed() { // 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。 // 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted` @@ -944,7 +942,7 @@ impl ExecutionAdapter { } /// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。 - pub(super) fn turn_failure(&self) -> Option { + pub(super) fn turn_failure(&self) -> Option { self.turn_failure.lock().ok().and_then(|slot| slot.clone()) } @@ -1041,7 +1039,7 @@ impl ExecutionAdapter { pub(super) fn lifecycle_status(&self, fallback: &str) -> String { // 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由 - // `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 + // `turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 // `Interrupted` 抹成一次没有原因的"已结束"。 match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", @@ -1335,7 +1333,7 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { - use super::super::DirectTurnDeadline; + use super::super::{Deadline, TimedOut, TransportClosed}; use super::*; @@ -1391,34 +1389,28 @@ mod tests { assert!(!adapter.host_stop_requested()); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }) + })) .await; // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 let failure = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!( - failure.wire_kind(), - Some(super::super::DirectTurnFailureKind::TransportFailed) - ); - assert!(failure.to_string().contains("SIGKILL")); + assert!(matches!(failure, TurnError::TransportClosed(_))); + assert!(failure.diagnostic_detail().contains("SIGKILL")); // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) .await; let failure = adapter.turn_failure().expect("first reason is kept"); - assert_eq!( - failure.wire_kind(), - Some(super::super::DirectTurnFailureKind::TransportFailed) - ); - assert!(failure.to_string().contains("SIGKILL")); - assert!(!failure.to_string().contains("超时")); + assert!(matches!(failure, TurnError::TransportClosed(_))); + assert!(failure.diagnostic_detail().contains("SIGKILL")); + assert!(!failure.diagnostic_detail().contains("超时")); } /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 @@ -1430,9 +1422,9 @@ mod tests { adapter.closed.store(true, Ordering::Release); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "模型本次执行结束,回收原生后台子树".into(), - }) + })) .await; assert!(adapter.turn_failure().is_none()); @@ -1457,9 +1449,9 @@ mod tests { adapter.request_host_stop(); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }) + })) .await; assert!(adapter.turn_failure().is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 1344ddaae..a1141d90e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -16,6 +16,7 @@ use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; mod model_catalog; +pub(crate) mod turn_error; pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; use execution::{ExecutionAdapter, ResponseDeliveryFailure}; @@ -640,7 +641,7 @@ enum CodexTurnEvent { } #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum DirectCodexTurnObservation { +pub(crate) enum TurnObservation { AccumulatedText(String), /// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。 AgentMessageSegment(String), @@ -793,14 +794,11 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 +/// 运行态事件载荷:与历史切片同形的原始条目;拿不到身份或类型就整条跳过。 /// -/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 -fn direct_thread_event_item( - root: &std::path::Path, - item: &serde_json::Value, -) -> Option { - thread_item_from_value(root, item, direct_now_ms()) +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责;脱敏也在前端做。 +fn direct_thread_event_item(item: &serde_json::Value) -> Option { + thread_item_from_value(item, now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -812,14 +810,11 @@ fn direct_thread_event_item( /// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 /// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 /// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 -fn direct_thread_visible_item( - root: &std::path::Path, - item: &serde_json::Value, -) -> Option { +fn direct_thread_visible_item(item: &serde_json::Value) -> Option { if is_direct_project_codex_user_item(item) { return None; } - direct_thread_event_item(root, item) + direct_thread_event_item(item) } /// 下发本轮的开口用户条目:放行之后、起 codex 之前的第一条运行态条目。 @@ -836,7 +831,7 @@ pub(crate) fn emit_direct_thread_user_item( root: &std::path::Path, item: &serde_json::Value, ) -> Option { - let entry_item = direct_thread_event_item(root, item)?; + let entry_item = direct_thread_event_item(item)?; // 条目时间是落盘 / 观测时间。前端乐观用户气泡已删(ADR「DirectProject命令入队化」), // 所以这就是界面显示这条用户消息的唯一时间口径:它晚于用户按下发送,但不再有第二份更早的时间。 let at = entry_item.at(); @@ -1184,14 +1179,14 @@ fn direct_codex_reasoning_delta_event( }) } -/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。 +/// DirectProject 的流式正文原样进事件流;脱敏在事件进前端状态时统一做(前端 +/// `directThreadSanitize`)。 fn direct_codex_thread_delta_event( - root: &std::path::Path, item_id: String, kind: ThreadDeltaKind, delta: &str, ) -> ThreadEvent { - ThreadEvent::item_delta(item_id, kind, thread_delta_text(root, delta)) + ThreadEvent::item_delta(item_id, kind, delta.to_string()) } /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// @@ -1639,7 +1634,7 @@ async fn stage_codex_app_server_image( } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum DirectCodexTurnKind { +pub(crate) enum TurnKind { User, HostFeedback, } @@ -1648,14 +1643,14 @@ async fn direct_project_turn_input( request: &LlmRunRequest, prompt: &str, workspace_path: &std::path::Path, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, original_user_item: Option<&serde_json::Value>, skill_roots: &[std::path::PathBuf], ) -> Result { // 原始用户条目一直用于历史和事件关联,但只有首次请求将它作为模型输入。 - if turn_kind == DirectCodexTurnKind::User { + if turn_kind == TurnKind::User { if let Some(item) = original_user_item { - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + let canonical: UserItem = serde_json::from_value(item.clone()) .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; return direct_codex_user_item_to_codex_turn_input( workspace_path, @@ -3302,7 +3297,7 @@ impl CodexAppServerConnection { .await // 非 Direct 入口没有"继续返修批次"这条控制流(那是封口复核才有的),遇上只当一次普通的 // 请求被拒;Direct 回合走下面的 `_and_history` 版本,控制流在那里有自己的变体。 - .map_err(DirectTurnRunFailure::into_llm_error) + .map_err(RunFailure::into_llm_error) } async fn run_turn_with_direct_observer( @@ -3311,8 +3306,8 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, - direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + direct_observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) -> Result { self.run_turn_with_direct_observer_and_history( snapshot, llm, @@ -3320,7 +3315,7 @@ impl CodexAppServerConnection { None, None, None, - DirectCodexTurnKind::User, + TurnKind::User, on_agent_message_delta, direct_observer, ) @@ -3335,10 +3330,10 @@ impl CodexAppServerConnection { direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, direct_user_item: Option<&serde_json::Value>, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, - mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + mut direct_observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); // 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), @@ -3356,11 +3351,9 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::InvalidRequest( - "DirectProject 用户消息不能为空".to_string(), - ), - )); + return Err(RunFailure::from(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + ))); } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { @@ -3419,7 +3412,7 @@ impl CodexAppServerConnection { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { let text = execution::outcome_text(outcome).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3431,9 +3424,7 @@ impl CodexAppServerConnection { Ok(Some(params)) => { if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport(error), - )); + return Err(RunFailure::from(platform_llm::LlmError::Transport(error))); } } Err(error) => { @@ -3574,9 +3565,9 @@ impl CodexAppServerConnection { let text = execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, format!("turn/start 终态未知:{error}"), @@ -3592,7 +3583,7 @@ impl CodexAppServerConnection { { Some(turn_id) => turn_id.to_string(), None => { - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "turn/start 响应缺少 turn.id", @@ -3609,7 +3600,7 @@ impl CodexAppServerConnection { let text = execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } } turn_start_guard.armed = false; @@ -3627,7 +3618,7 @@ impl CodexAppServerConnection { // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 - let direct_turn_started_at_ms = direct_now_ms(); + let direct_turn_started_at_ms = now_ms(); let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -3658,9 +3649,9 @@ impl CodexAppServerConnection { if let Some(adapter) = approval_adapter.as_ref() { // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::TurnHardLimit, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + })) .await; return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); @@ -3690,14 +3681,14 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) .await; return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "等待 turn/completed 超时", @@ -3715,7 +3706,6 @@ impl CodexAppServerConnection { // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 direct_codex_thread_delta_event( - history_root, item_id.clone(), ThreadDeltaKind::Message, &delta, @@ -3724,17 +3714,13 @@ impl CodexAppServerConnection { } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AccumulatedText( - streamed_text.clone(), - )); + observer(TurnObservation::AccumulatedText(streamed_text.clone())); // 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。 let segment_text = direct_project_history .accumulated_text_for(&item_id) .unwrap_or_else(|| delta.clone()); if !segment_text.trim().is_empty() { - observer(DirectCodexTurnObservation::AgentMessageSegment( - segment_text, - )); + observer(TurnObservation::AgentMessageSegment(segment_text)); } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { @@ -3752,7 +3738,6 @@ impl CodexAppServerConnection { append_thread_event( &direct_thread_id, direct_codex_thread_delta_event( - history_root, item_id, ThreadDeltaKind::Reasoning, &delta, @@ -3762,19 +3747,17 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::IntermediateText(text)); + observer(TurnObservation::IntermediateText(text)); } } Some(CodexTurnEvent::RawItem(item)) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if item.is_null() { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Deserialize( - "rawResponseItem/completed 缺少 item".to_string(), - ), - )); + return Err(RunFailure::from(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + ))); } - let entry_item = direct_thread_visible_item(history_root, &item); + let entry_item = direct_thread_visible_item(&item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3793,7 +3776,7 @@ impl CodexAppServerConnection { // 通知的钟就是该阶段唯一可证明的时间。 append_thread_event( &direct_thread_id, - ThreadEvent::item_completed(entry_item, direct_now_ms()), + ThreadEvent::item_completed(entry_item, now_ms()), ); } } @@ -3814,7 +3797,7 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::Activity(activity)); + observer(TurnObservation::Activity(activity)); } } Some(CodexTurnEvent::Item { completed, params }) => { @@ -3824,7 +3807,7 @@ impl CodexAppServerConnection { .and_then(serde_json::Value::as_str) .unwrap_or_default(); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::Activity( + observer(TurnObservation::Activity( direct_codex_safe_activity_for_item_value(item), )); // item/started 在工具真正开始执行时到达,携带 @@ -3834,12 +3817,10 @@ impl CodexAppServerConnection { if !completed { if let Some(reasoning) = direct_codex_item_reasoning_text(item) { - observer(DirectCodexTurnObservation::Reasoning(reasoning)); + observer(TurnObservation::Reasoning(reasoning)); } if let Some(text) = direct_codex_item_intermediate_text(item) { - observer(DirectCodexTurnObservation::IntermediateText( - text, - )); + observer(TurnObservation::IntermediateText(text)); } } } @@ -3863,11 +3844,9 @@ impl CodexAppServerConnection { .filter(|value| !value.trim().is_empty()) { if let Some(observer) = direct_observer.as_deref_mut() { - observer( - DirectCodexTurnObservation::AgentMessageSegment( - text.to_string(), - ), - ); + observer(TurnObservation::AgentMessageSegment( + text.to_string(), + )); } } } @@ -3884,7 +3863,7 @@ impl CodexAppServerConnection { "userMessage" | "plan" | "reasoning" | "contextCompaction" ) { - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( platform_llm::LlmError::InvalidRequest(format!( "Codex app-server 违反 {}边界,产生非被动 item {item_type}", self.inner.workspace_mode.passive_item_boundary_name(), @@ -3895,21 +3874,14 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - if let Some(entry_item) = - direct_thread_visible_item(history_root, item) - { + if let Some(entry_item) = direct_thread_visible_item(item) { // `item/started` 的通知层带 `startedAtMs`:这是工具真正 // 开始的阶段时间,优先于条目展示时间与宿主钟。 append_thread_event( &direct_thread_id, ThreadEvent::item_started( entry_item, - thread_item_event_at_ms( - ¶ms, - item, - false, - direct_now_ms(), - ), + thread_item_event_at_ms(¶ms, item, false, now_ms()), ), ); } @@ -3933,7 +3905,7 @@ impl CodexAppServerConnection { { final_text = Some(text.to_string()); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment( + observer(TurnObservation::AgentMessageSegment( text.to_string(), )); } @@ -3952,7 +3924,7 @@ impl CodexAppServerConnection { thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), - direct_now_ms(), + now_ms(), ), )); } @@ -3968,9 +3940,7 @@ impl CodexAppServerConnection { return final_text .filter(|text| !text.trim().is_empty()) .ok_or_else(|| { - DirectTurnRunFailure::from( - platform_llm::LlmError::EmptyResponse, - ) + RunFailure::from(platform_llm::LlmError::EmptyResponse) }); } "interrupted" => { @@ -3981,17 +3951,19 @@ impl CodexAppServerConnection { // 不在这里再写一遍。 if !adapter.is_host_ending() { adapter - .fail_turn(DirectTurnError::TurnInterrupted { - detail: - "本轮模型执行被中断,正在核对自有后台进程。" - .into(), - }) + .fail_turn(TurnError::TurnInterrupted( + TurnInterrupted { + detail: + "本轮模型执行被中断,正在核对自有后台进程。" + .into(), + }, + )) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( platform_llm::LlmError::InvalidRequest( "Codex app-server turn 已中断".to_string(), ), @@ -4016,14 +3988,12 @@ impl CodexAppServerConnection { } } } - return Err(DirectTurnRunFailure::from(native)); + return Err(RunFailure::from(native)); } status => { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Deserialize(format!( - "Codex app-server turn/completed 状态无效:{status}" - )), - )) + return Err(RunFailure::from(platform_llm::LlmError::Deserialize( + format!("Codex app-server turn/completed 状态无效:{status}"), + ))) } } } @@ -4034,15 +4004,15 @@ impl CodexAppServerConnection { // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: error.clone(), - }) + })) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, error, @@ -4056,15 +4026,15 @@ impl CodexAppServerConnection { // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 // 以失败收口,不能报成"被中断"。 adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server turn 事件通道已关闭".into(), - }) + })) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "Codex app-server turn 事件通道已关闭", @@ -4075,13 +4045,13 @@ impl CodexAppServerConnection { } } }; - let mut collect_result: Result = collect.await; + let mut collect_result: Result = collect.await; // Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。 - let mut direct_terminal: Option = None; + let mut direct_terminal: Option = None; // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 - if let (Some(adapter), Err(DirectTurnRunFailure::Failed(error))) = + if let (Some(adapter), Err(RunFailure::Failed(error))) = (approval_adapter.as_ref(), &collect_result) { if !adapter.is_settled() { @@ -4102,11 +4072,9 @@ impl CodexAppServerConnection { }) .await .unwrap_or_else(|_| { - Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport( - "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), - ), - )) + Err(RunFailure::from(platform_llm::LlmError::Transport( + "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + ))) }); let fallback_status = model_terminal .as_ref() @@ -4122,12 +4090,12 @@ impl CodexAppServerConnection { .unwrap_or_else(|| fallback_status.to_string()); // 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。 let completed_at = if approval_adapter.is_some() { - direct_now_ms() + now_ms() } else { model_terminal .as_ref() .map(|(_, at)| *at) - .unwrap_or_else(direct_now_ms) + .unwrap_or_else(now_ms) }; // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` @@ -4137,7 +4105,7 @@ impl CodexAppServerConnection { // 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败), // 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把 // 已经失败的回合讲成"已结束"。 - direct_terminal = Some(DirectTurnTerminalContext { + direct_terminal = Some(TerminalContext { status, completed_at, host_failure: approval_adapter @@ -4157,11 +4125,11 @@ impl CodexAppServerConnection { // 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是 // `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有 // 回复、没有任何解释"。 - let turn_result: Result = match collect_result { + let turn_result: Result = match collect_result { Ok(text) => match parse_game_creator_codex_app_server_text(&text, &thread_id, &request) { - Ok(response) => Ok(DirectTurnReport { text, response }), - Err(error) => Err(DirectTurnRunFailure::Failed(error)), + Ok(response) => Ok(TurnReport { text, response }), + Err(error) => Err(RunFailure::Failed(error)), }, Err(failure) => Err(failure), }; @@ -4196,20 +4164,20 @@ impl CodexAppServerConnection { /// `LlmError::InvalidRequest("validation-source-changed: …")`,于是和真失败共用一条投影——终态被 /// 判成 `failed`、界面收到一条用户可见的失败说明,外层还会把它当"可修复错误"再喂给模型。 #[derive(Debug)] -enum DirectTurnRunFailure { +enum RunFailure { /// 这一轮真的失败了(模型 / 传输 / 交付 / 解析)。 Failed(platform_llm::LlmError), /// 封口复核要求继续当前返修批次:**不写终态**,由调用方把 `detail` 写回提示词继续跑。 RepairRequired { detail: String }, } -impl From for DirectTurnRunFailure { +impl From for RunFailure { fn from(error: platform_llm::LlmError) -> Self { Self::Failed(error) } } -impl DirectTurnRunFailure { +impl RunFailure { /// 压回 `LlmError`:只给拿不到"继续返修批次"这条控制流的入口用(非 Direct 的 `run_turn`)。 fn into_llm_error(self) -> platform_llm::LlmError { match self { @@ -4221,19 +4189,17 @@ impl DirectTurnRunFailure { /// 这一轮要不要写终态、写什么内容。 /// -/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定); +/// - `report` 有值:正常终态(报告正文交给 `turn_terminal` 兜底判定); /// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影; /// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 /// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 fn direct_turn_terminal_write<'a>( report: Option<&'a str>, - failure: Option<&DirectTurnRunFailure>, -) -> Option> { + failure: Option<&RunFailure>, +) -> Option> { match failure { - Some(DirectTurnRunFailure::Failed(error)) => { - Some(Err(DirectTurnError::from_model_call(error))) - } - Some(DirectTurnRunFailure::RepairRequired { .. }) => None, + Some(RunFailure::Failed(error)) => Some(Err(TurnError::from_model_call(error))), + Some(RunFailure::RepairRequired { .. }) => None, None => report.map(Ok), } } @@ -4242,7 +4208,7 @@ fn direct_turn_terminal_write<'a>( /// /// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份 /// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。 -struct DirectTurnReport { +struct TurnReport { /// 可展示的回复 / 交付报告正文。 text: String, /// 解析后的响应。 @@ -4253,23 +4219,23 @@ struct DirectTurnReport { /// /// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上 /// 无解——前端只会看到一次没有解释的"已结束"。 -struct DirectTurnTerminalContext { +struct TerminalContext { /// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。 status: String, /// 终态事件的宿主观测时刻。 completed_at: u64, /// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。 - host_failure: Option, + host_failure: Option, /// 逻辑回合身份与开口的用户条目身份。 thread_id: String, user_item_id: Option, } -impl DirectTurnTerminalContext { +impl TerminalContext { /// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果: /// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。 - fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) { - let terminal = direct_turn_terminal( + fn write(&self, collect_outcome: Result<&str, TurnError>, history_root: &Path) { + let terminal = turn_terminal( &self.status, collect_outcome, self.host_failure.as_ref(), @@ -4283,7 +4249,7 @@ impl DirectTurnTerminalContext { } } -impl std::fmt::Display for DirectTurnRunFailure { +impl std::fmt::Display for RunFailure { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Failed(error) => write!(formatter, "{error}"), @@ -4295,8 +4261,8 @@ impl std::fmt::Display for DirectTurnRunFailure { fn finish_direct_project_collect_history( root: &Path, mut history: DirectProjectHistoryAccumulator, - result: Result, -) -> Result { + result: Result, +) -> Result { // 宿主预算/交付终态也会返回 Ok(report),同样需要保留被中断的流式正文。 if let Err(persist_error) = persist_direct_project_partial_items_at(root, &mut history) { let prior = result @@ -4304,11 +4270,9 @@ fn finish_direct_project_collect_history( .err() .map(|error| format!(";原始回合错误:{error}")) .unwrap_or_default(); - return Err(DirectTurnRunFailure::Failed( - platform_llm::LlmError::Transport(format!( - "DirectProject 收尾历史失败:{persist_error}{prior}" - )), - )); + return Err(RunFailure::Failed(platform_llm::LlmError::Transport( + format!("DirectProject 收尾历史失败:{persist_error}{prior}"), + ))); } result } @@ -4330,11 +4294,11 @@ impl Drop for CodexTurnStartGuard { } /// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。 -struct DirectCodexActiveTurnTable { +struct ActiveTurnTable { entries: HashMap, } -impl DirectCodexActiveTurnTable { +impl ActiveTurnTable { fn new() -> Self { Self { entries: HashMap::new(), @@ -4390,15 +4354,15 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另 /// /// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 /// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由 -/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 +/// [`ActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock< - std::sync::Mutex>>, + std::sync::Mutex>>, > = OnceLock::new(); fn direct_codex_active_turns( -) -> &'static std::sync::Mutex>> { +) -> &'static std::sync::Mutex>> { GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS - .get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new())) + .get_or_init(|| std::sync::Mutex::new(ActiveTurnTable::new())) } /// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀), @@ -4414,12 +4378,12 @@ fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf { } } -struct DirectCodexActiveTurnGuard { +struct ActiveTurnGuard { key: std::path::PathBuf, cancellation: Arc, } -impl Drop for DirectCodexActiveTurnGuard { +impl Drop for ActiveTurnGuard { fn drop(&mut self) { let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else { return; @@ -4437,11 +4401,11 @@ fn register_active_direct_codex_turn( key: std::path::PathBuf, client_turn_id: &str, cancellation: Arc, -) -> DirectCodexActiveTurnGuard { +) -> ActiveTurnGuard { if let Ok(mut entries) = direct_codex_active_turns().lock() { entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation)); } - DirectCodexActiveTurnGuard { key, cancellation } + ActiveTurnGuard { key, cancellation } } /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 @@ -4452,7 +4416,7 @@ pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct DirectTurnCancelView { +pub(crate) struct TurnCancelView { /// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或 /// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。 pub(crate) outcome: String, @@ -4463,7 +4427,7 @@ pub(crate) struct DirectTurnCancelView { } /// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 -enum DirectCodexTurnCancelTarget { +enum TurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 @@ -4489,7 +4453,7 @@ enum DirectCodexTurnCancelTarget { pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, -) -> Result { +) -> Result { if let Some(view) = cancel_direct_claude_code_turn_at(root, client_turn_id)? { return Ok(view); } @@ -4515,18 +4479,16 @@ pub(crate) fn cancel_direct_codex_turn_at( .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; match entries.select(&key, client_turn_id) { Ok((_, cancellation)) if cancellation.app_server_alive() => { - DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) - } - Ok(_) => DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), - Err(_) => { - DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor) + TurnCancelTarget::Interrupt(Arc::clone(cancellation)) } + Ok(_) => TurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), + Err(_) => TurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor), } }; match target { - DirectCodexTurnCancelTarget::Interrupt(cancellation) => { + TurnCancelTarget::Interrupt(cancellation) => { cancellation.cancel(); - Ok(DirectTurnCancelView { + Ok(TurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), message: "已向正在运行的回合发出终止".to_string(), client_turn_id: client_turn_id @@ -4536,7 +4498,7 @@ pub(crate) fn cancel_direct_codex_turn_at( .to_string(), }) } - DirectCodexTurnCancelTarget::Stale(reason) => { + TurnCancelTarget::Stale(reason) => { // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 @@ -4544,7 +4506,7 @@ pub(crate) fn cancel_direct_codex_turn_at( // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_queue_dispatch(root); - Ok(DirectTurnCancelView { + Ok(TurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( "{},已释放这一轮的占用,可以直接重新发送消息", @@ -5179,14 +5141,14 @@ async fn fail_game_creator_codex_app_server_connection( // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再让"连接 // 已死"对看门狗可见(`shutdown_game_creator_codex_app_server_inner` 才置 `closed`)。顺序不能 // 反——执行适配器的看门狗盯着 `closed`,它一旦先醒就会把本轮收束成"被中断";而失败事实是在 - // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `DirectTurnTerminalContext`), + // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `TerminalContext`), // 晚一步补记没有意义,界面只会看到"本轮已结束、没有原因"。 // 这一段中间有两次加锁和一个日志写,都可能让出线程;认领标志保证只有第一个观察者走到这里。 record_execution_turn_failure( &inner, - DirectTurnError::TransportClosed { + TurnError::TransportClosed(TransportClosed { diagnostic: diagnostic.clone(), - }, + }), ) .await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { @@ -5199,7 +5161,7 @@ async fn fail_game_creator_codex_app_server_connection( /// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server /// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) /// 就是空操作。 -async fn record_execution_turn_failure(inner: &Arc, failure: DirectTurnError) { +async fn record_execution_turn_failure(inner: &Arc, failure: TurnError) { let adapter = { let slot = match inner.execution.lock() { Ok(slot) => slot, @@ -5285,12 +5247,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, user_prompt, - DirectCodexTurnKind::User, + TurnKind::User, None, None, None, @@ -5302,17 +5264,17 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, direct_user_item: Option, -) -> Result { +) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between // projects, or replace the project manifest in-place; raw path text alone // must never select a connection created for the previous project. let (canonical_root, project_id) = direct_codex_canonical_project_identity(root) - .map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?; + .map_err(|cause| TurnError::ProjectRootUnanchored(ProjectRootUnanchored { cause }))?; let codex_root = if let Some(path) = canonical_root .to_str() .and_then(|value| value.strip_prefix("\\\\?\\")) @@ -5322,10 +5284,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( canonical_root.clone() }; let config = load_game_creator_app_config() - .map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?; + .map_err(|detail| TurnError::EnvironmentNotReady(EnvironmentNotReady { detail }))?; if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI { if let Some(error) = game_creator_claude_code_cli_route_error(&config.llm) { - return Err(DirectTurnError::EnvironmentNotReady { detail: error }); + return Err(TurnError::EnvironmentNotReady(EnvironmentNotReady { + detail: error, + })); } return direct_game_creator_claude_code_chat_at( &codex_root, @@ -5337,13 +5301,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( ) .await .map_err(|detail| { - DirectTurnError::from_model_call(&platform_llm::LlmError::Transport(detail)) + TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) }); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; // 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。 let generated_client_turn_id; @@ -5357,10 +5321,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".to_string(), + .map_err(|_| { + TurnError::HostStateUnavailable(HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".to_string(), + }) })? - .map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?; + .map_err(|detail| TurnError::HostStateUnavailable(HostStateUnavailable { detail }))?; Some(generated_client_turn_id.as_str()) } }; @@ -5381,9 +5347,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( allow_idle_context_compaction: false, }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| { - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; let connection = Box::pin(CodexAppServerConnection::acquire_at_workspace( &snapshot, @@ -5395,9 +5361,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .await; let connection = connection.map_err(|error| { // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。 - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) @@ -5420,10 +5386,8 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|value| value.text) // 真失败才投影成回合失败;"继续返修批次"这条控制流有自己的变体,直接交给调用方。 .map_err(|failure| match failure { - DirectTurnRunFailure::Failed(error) => DirectTurnError::from_model_call(&error), - DirectTurnRunFailure::RepairRequired { detail } => { - DirectTurnError::RepairRequired { detail } - } + RunFailure::Failed(error) => TurnError::from_model_call(&error), + RunFailure::RepairRequired { detail } => TurnError::RepairRequired { detail }, }) } @@ -5594,7 +5558,7 @@ mod tests { Path::new("not-read"), String::new(), String::new(), - DirectCodexTurnKind::User, + TurnKind::User, Some("not-executed"), None, None, @@ -5618,7 +5582,7 @@ mod tests { /// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。 #[test] fn direct_codex_active_turn_table_selects_only_the_running_turn() { - let mut table: DirectCodexActiveTurnTable = DirectCodexActiveTurnTable::new(); + let mut table: ActiveTurnTable = ActiveTurnTable::new(); let key = std::path::PathBuf::from("C:/projects/direct-turn-demo"); assert_eq!( table.select(&key, None).expect_err("no active turn"), @@ -5727,7 +5691,7 @@ mod tests { failure: None, at: Some(_), user_item_id: Some(user_item_id), - }] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user" + }] if *status == TurnCompletedStatus::Aborted && user_item_id == "direct-codex:client-turn-exited:user" )); let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next"); @@ -5780,8 +5744,8 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 - let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + // 运行态事件必须自足:载荷是原始条目,前端不需要再按 itemId 取快照。 + let projected = direct_thread_event_item(&item).expect("item"); assert_eq!(projected.item_id(), "item-1"); let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( @@ -5797,12 +5761,11 @@ mod tests { assert!(payload.get("title").is_none(), "{payload}"); assert!(payload.get("summary").is_none(), "{payload}"); assert!(payload.get("kind").is_none(), "{payload}"); - // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 - let arguments = payload - .get("arguments") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - assert!(!arguments.contains("\"secret\""), "{payload}"); + // 脱敏不在这一层做:原始参数原样下发,密钥由前端"事件进状态"时统一抹掉。 + assert_eq!( + payload.get("arguments").and_then(serde_json::Value::as_str), + Some("{\n \"path\": \"game/index.html\",\n \"token\": \"secret\"\n}") + ); } #[test] @@ -6065,56 +6028,18 @@ mod tests { } #[test] - fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { - let root = std::path::Path::new("/workspace/direct-project"); + fn direct_thread_message_and_reasoning_deltas_are_passed_through_verbatim() { let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; for kind in [ThreadDeltaKind::Message, ThreadDeltaKind::Reasoning] { - let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); + let event = direct_codex_thread_delta_event("item-1".to_string(), kind, raw); let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); assert!( - !wire.contains("sk-abcdefghijklmnop"), - "不得泄漏密钥:{wire}" - ); - assert!( - !wire.contains("/root/secret.txt"), - "不得泄漏绝对路径:{wire}" - ); - assert!( - wire.contains("assets/a.png"), - "项目内绝对路径应归一成相对路径:{wire}" + wire.contains(raw), + "增量原样进事件流,脱敏由前端统一做:{wire}" ); } } - /// 流式脱敏必须保留增量自带的换行:前端把每个 `item.delta` 的脱敏结果**直接拼接**成一条 - /// 消息再交给 Markdown 渲染,所以「逐段脱敏」必须与「整段脱敏」同形,不能吃掉段尾换行。 - /// - /// 回归背景:`sanitize_error_context` 曾用 `lines()` + `join("\n")` 往返,把以换行结尾的段 - /// 的末尾换行吃掉。真实会话里段落、列表项和表格行会因此并进同一行(表头、`|---|---|` 与 - /// 数据行挤成一段正文),整条汇报的 Markdown 结构——尤其表格——直接失效。 - #[test] - fn direct_thread_delta_sanitization_preserves_line_breaks() { - let root = std::path::Path::new("/workspace/direct-project"); - let chunks = [ - "共 4 项验收要求。\n", - "\n", - "| 素材 | 用途 |\n", - "|---|---|\n", - "| 飞鸟角色 | 玩家角色 |\n", - ]; - let streamed: String = chunks - .iter() - .map(|chunk| thread_delta_text(root, chunk)) - .collect(); - let whole = chunks.concat(); - assert_eq!(streamed, whole, "逐段脱敏不得吃掉段尾换行"); - assert_eq!( - thread_delta_text(root, &whole), - streamed, - "逐段脱敏与整段脱敏必须同形" - ); - } - /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 /// /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 @@ -6271,7 +6196,7 @@ mod tests { /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 #[test] fn terminal_write_skips_the_repair_request_and_projects_real_failures() { - let repair = DirectTurnRunFailure::RepairRequired { + let repair = RunFailure::RepairRequired { detail: "继续当前返修批次".into(), }; assert!( @@ -6279,28 +6204,29 @@ mod tests { "返修要求是控制流,不允许写终态" ); - let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport( - "执行通道已断开".into(), - )); + let failed = RunFailure::Failed(platform_llm::LlmError::Transport("执行通道已断开".into())); let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); - let error = payload.expect_err("失败终态必须带载荷"); - assert_eq!( - error.wire_kind(), - Some(crate::agent::DirectTurnFailureKind::TransportFailed) - ); + match payload.expect_err("失败终态必须带载荷") { + // 传输类 `LlmError` 投影成 `ModelCallFailed` 的 `TransportBroken`:载荷带 typed 分类, + // 不是另立一个"通道关闭"变体。 + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, ModelCallKind::TransportBroken) + } + other => panic!("执行通道断开必须投影成模型调用失败,得到 {other:?}"), + } // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` // 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。 - let parse_failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Deserialize( + let parse_failed = RunFailure::Failed(platform_llm::LlmError::Deserialize( "Codex app-server structured output 不是严格 JSON".into(), )); let payload = direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); - assert_eq!( - payload.expect_err("解析失败必须带载荷").wire_kind(), - Some(crate::agent::DirectTurnFailureKind::ModelFailed) - ); + assert!(matches!( + payload.expect_err("解析失败必须带载荷"), + TurnError::ModelCallFailed(_) + )); let payload = direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态"); @@ -6424,7 +6350,7 @@ mod tests { &request, &direct_codex_user_prompt(&request), temp.path(), - DirectCodexTurnKind::User, + TurnKind::User, Some(&user_item), &skill_roots, ) @@ -6448,7 +6374,7 @@ mod tests { &request, &direct_codex_user_prompt(&request), temp.path(), - DirectCodexTurnKind::HostFeedback, + TurnKind::HostFeedback, Some(&user_item), &skill_roots, ) @@ -6473,12 +6399,9 @@ mod tests { ) .expect("freeze original CLI input"); for (kind, prompt) in [ - (DirectCodexTurnKind::User, "请创建菜单"), - ( - DirectCodexTurnKind::HostFeedback, - "宿主验收未通过:缺少玩法证据", - ), - (DirectCodexTurnKind::HostFeedback, "构建失败:请修复入口"), + (TurnKind::User, "请创建菜单"), + (TurnKind::HostFeedback, "宿主验收未通过:缺少玩法证据"), + (TurnKind::HostFeedback, "构建失败:请修复入口"), ] { // 与发送入口一样,每次先幂等落盘原始用户条目,再构造本次模型输入。 append_direct_project_user_message_at(temp.path(), &user_item) @@ -6519,16 +6442,10 @@ mod tests { let prompt = codex_app_server_text_prompt(&request).expect("sanitized prompt"); assert!(!prompt.contains("data:image")); assert!(prompt.contains("原生视觉输入")); - let input = direct_project_turn_input( - &request, - &prompt, - temp.path(), - DirectCodexTurnKind::User, - None, - &[], - ) - .await - .expect("turn input"); + let input = + direct_project_turn_input(&request, &prompt, temp.path(), TurnKind::User, None, &[]) + .await + .expect("turn input"); assert_eq!(input[0]["type"], "text"); assert_eq!(input[1]["type"], "localImage"); let staged_path = input[1]["path"].as_str().expect("staged path"); @@ -8032,45 +7949,33 @@ while IFS= read -r line; do :; done assert_eq!(streamed, "{\"toolCalls\":"); assert_eq!( observations.first(), - Some(&DirectCodexTurnObservation::Activity("preparing")), + Some(&TurnObservation::Activity("preparing")), "turn/started must produce safe activity before terminal completion" ); let delta_index = observations .iter() .position(|observation| { - *observation - == DirectCodexTurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) + *observation == TurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) }) .expect("agent message delta observation"); assert!( observations[..delta_index] .iter() - .filter(|observation| matches!( - observation, - DirectCodexTurnObservation::Activity(_) - )) + .filter(|observation| matches!(observation, TurnObservation::Activity(_))) .count() >= 4, "real long-tool protocol activity must be visible before final answer delta" ); + assert!(observations + .iter() + .any(|observation| { matches!(observation, TurnObservation::Activity("file-write")) })); assert!(observations.iter().any(|observation| { - matches!( - observation, - DirectCodexTurnObservation::Activity("file-write") - ) - })); - assert!(observations.iter().any(|observation| { - matches!( - observation, - DirectCodexTurnObservation::Activity("command-exec") - ) + matches!(observation, TurnObservation::Activity("command-exec")) })); assert!( observations .iter() - .filter(|observation| { - **observation == DirectCodexTurnObservation::Activity("preparing") - }) + .filter(|observation| { **observation == TurnObservation::Activity("preparing") }) .count() >= 2, "turn start and later plan activity must both remain visible" @@ -8079,7 +7984,7 @@ while IFS= read -r line; do :; done observations .iter() .filter(|observation| { - **observation == DirectCodexTurnObservation::Activity("response-finalization") + **observation == TurnObservation::Activity("response-finalization") }) .count(), 2, @@ -8158,10 +8063,9 @@ while IFS= read -r line; do :; done .expect("run fake app-server turn"); drop(observer); assert!( - !observations.iter().any(|observation| matches!( - observation, - DirectCodexTurnObservation::Activity("preparing") - )), + !observations + .iter() + .any(|observation| matches!(observation, TurnObservation::Activity("preparing"))), "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" ); assert!( @@ -8279,7 +8183,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), - DirectCodexTurnKind::User, + TurnKind::User, None, Some(&mut observer), ) @@ -8311,23 +8215,29 @@ done .find_map(|event| match event { ThreadEvent::TurnCompleted { status, failure, .. - } => Some((status.clone(), failure.clone())), + } => Some((*status, failure.clone())), _ => None, }) .expect("连接死亡之后逻辑回合必须有终态"); assert_eq!( - terminal.0, "failed", + terminal.0, + TurnCompletedStatus::Failed, "失败事实必须先落地:{:?}", consumed.events ); let failure = terminal .1 .expect("连接死亡必须带失败载荷,否则界面只会看到「本轮已结束」"); - assert_eq!( - failure.kind, - crate::agent::DirectTurnFailureKind::TransportFailed - ); - assert!(failure.message.contains("已退出"), "{}", failure.message); + match &failure { + crate::agent::TurnFailure::TransportClosed(payload) => { + assert!( + payload.diagnostic.contains("已退出"), + "{}", + payload.diagnostic + ) + } + other => panic!("连接死亡必须是 transport-closed 载荷,got {other:?}"), + } } #[cfg(unix)] @@ -8434,7 +8344,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), - DirectCodexTurnKind::User, + TurnKind::User, None, Some(&mut observer), ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs new file mode 100644 index 000000000..745a9c1c2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -0,0 +1,1414 @@ +//! Direct 链路的 typed error:三条通道三张类型表,**没有一处靠字符串分流或上屏**。 +//! +//! | 类型 | 通道 | 变体 | +//! | --- | --- | --- | +//! | [`EnqueueError`] | `#[tauri::command]` 的 `Err`(入队失败) | 9 | +//! | [`TurnError`] | 宿主内部(回合失败 + 控制流),**不导出、不跨进程** | 10 | +//! | [`TurnFailure`] | `turn.completed.failure` 事件载荷 | 9 | +//! +//! 走哪条通道由**发生位置**决定:入队前发生的进第一张表,放行后发生的进第二张,第二张里 +//! "这一轮已经判失败"的那部分由 [`TurnError::classify`] 投影成第三张。控制流(返修要求)不是 +//! 失败,分类成 [`TurnErrorClassified::ShouldContinue`],于是它既不会进失败载荷,也不需要前端接。 +//! +//! 为什么不是 `struct { kind, message }`:两张表根本不共享字段——模型自报失败要带原生分类、 +//! 等待超时要带是哪条上限、通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`, +//! 不靠 `kind` 字段 + 共用字段的伪结构化,也不靠对错误文本做子串匹配。 +//! +//! 载荷只装**原始事实**:文案由前端按变体拼(业务拒绝)或给固定句(系统事实),Rust 不再产出 +//! 任何用户可见文案。`detail` / `cause` / `diagnostic` 这类宿主原文留在载荷里,只用于分流与 +//! 诊断、不直接上屏。变体名就是线上的 `type`,载荷字段名就是线上的字段名。 +//! +//! 谁负责产生哪个变体: +//! - 命令入口(`direct_runtime::user_input`):入队失败; +//! - 回合编排(`direct_runtime`):阶段失败; +//! - app-server 投影([`TurnError::from_model_call`]):模型 / 上游 / 通道类失败; +//! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 +//! +//! TODO(文本判据):[`SuperErrorEnumFromStringTyped::classify`] 把**产生层交上来的字符串** +//! 读回 typed 事实。这是临时债,不是设计:上游(assets 私有凭据、项目写锁 / 历史追加锁、 +//! 项目历史形状、图集恢复、执行预算、试玩次数、工具参数)目前只交 `String` detail,没有 +//! typed 出口。修法在**上游**:让这些产生层直接产出 typed 变体 / 结构化子错误,判定层改成 +//! `match`;等上游补完,这张表和 `runtime_error::classify_direct_codex_error` 一起删掉。 +//! 补齐之前:**不许往表里加词**——新增分类必须先在产生层加 typed 变体。 +//! +//! 同一笔债还挂在两个载荷臂上:[`SuperErrorFromStringPlusStage`](有 stage、没有 typed detail) +//! 与 [`Unclassified`](连 stage 都没有)。名字故意起得又长又扎眼,就是在提醒它们不是设计: +//! `detail` typed 化之后,这两个名字都该消失。 + +use std::path::Path; + +use platform_llm::LlmError; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::agent::{redact_agent_runtime_error, TurnFailure}; + +/// 下发给前端的宿主原文(`detail` / `cause` / `diagnostic`)的字符上限:够说清原因,又不至于把 +/// 整段上游报文塞进事件队列。与改造前整条失败文案的上限同一档。 +const FAILURE_DETAIL_MAX_CHARS: usize = 600; + +/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 +/// +/// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 +/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`native_kind`] 这一个地方读它。 +const NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; + +/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 +/// +/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum FailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl FailureStage { + pub(crate) fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +/// 宿主等不到模型回执时,撞的是哪一条上限。 +/// +/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum Deadline { + /// 空闲上限:一段时间没有新事件。 + ResponseIdle, + /// 回合硬上限:整轮的总时间。 + TurnHardLimit, +} + +impl Deadline { + /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 + fn message(self) -> &'static str { + match self { + Self::ResponseIdle => "等待模型执行回执超时,不能自动重放未确认操作。", + Self::TurnHardLimit => "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", + } + } +} + +/// Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 +/// +/// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), +/// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 +/// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 +/// +/// 线上取值只给界面选语气用,前端不得拿它做流程分支。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum NativeKind { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + RequestTooLarge, + StreamRequired, + CyberPolicy, + SandboxError, + ThreadRollbackFailed, + BadRequest, + Unauthorized, + ActiveTurnNotSteerable, + /// 原生分类为 `other`,或本版本还不认识的新取值。 + Other { + kind: String, + }, +} + +impl NativeKind { + fn from_id(kind: &str) -> Self { + match kind { + "context-window-exceeded" => Self::ContextWindowExceeded, + "session-budget-exceeded" => Self::SessionBudgetExceeded, + "usage-limit-exceeded" => Self::UsageLimitExceeded, + "request-too-large" => Self::RequestTooLarge, + "stream-required" => Self::StreamRequired, + "cyber-policy" => Self::CyberPolicy, + "sandbox-error" => Self::SandboxError, + "thread-rollback-failed" => Self::ThreadRollbackFailed, + "bad-request" => Self::BadRequest, + "unauthorized" => Self::Unauthorized, + "active-turn-not-steerable" => Self::ActiveTurnNotSteerable, + kind => Self::Other { + kind: kind.to_string(), + }, + } + } + + /// 这一条分类是不是"再让模型跑一次也不会变好"。 + /// + /// 与旧行为逐条对齐:旧口径是"原因文本里出现这些分类名就不再反馈给模型",其余(含 `other` + /// 与未知分类)继续反馈。分类现在是 typed 的,判据不再依赖文本里出现过什么。 + fn is_terminal(&self) -> bool { + match self { + Self::ContextWindowExceeded + | Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::RequestTooLarge + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized => true, + Self::ActiveTurnNotSteerable | Self::Other { .. } => false, + } + } + + /// 给用户看的稳定摘要:只有"哪一类原因值得单独说一句"的分类才有。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded => Some("模型上下文已超限"), + Self::SessionBudgetExceeded => Some("本次会话预算已耗尽"), + Self::UsageLimitExceeded => Some("用量已达上限"), + Self::RequestTooLarge => Some("模型请求体过大"), + Self::CyberPolicy => Some("安全策略拒绝了本次请求"), + Self::SandboxError => Some("工作区隔离启动失败"), + _ => None, + } + } + + /// 恢复建议:分类决定动作;没把握的分类不给建议,交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => { + Some("请缩小本次需求范围或减少参考图后重试") + } + Self::SessionBudgetExceeded | Self::UsageLimitExceeded => { + Some("请在账户页面确认可用额度后重试,或新建项目继续") + } + Self::Unauthorized => Some("登录态可能已失效,请重新登录陶泥儿后重试"), + Self::CyberPolicy => Some("请调整本次需求的内容后重试"), + Self::SandboxError => Some("请检查项目目录权限后重试"), + Self::StreamRequired + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => None, + } + } + + /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— + /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 + /// + /// `Unauthorized` 必须与 [`SuperErrorEnumFromStringTyped::AuthenticationRejected`] 同口径:登录态失效重登 + /// 之后再发一次是有意义的,`recovery_hint` 也是这么写的。两套分类路径给出相反结论,会让同一 + /// 份事实的 `retryable` 取决于哪一层先认出它。 + fn is_retryable(&self) -> bool { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge | Self::Unauthorized => true, + Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => false, + } + } +} + +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 +/// +/// 每个变体对应平台层 `LlmError` 的一个分支;前端拿它选语气,不拿它做流程分支。 +/// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ModelCallKind { + /// `LlmError::Timeout`。 + ResponseTimedOut { attempts: u32 }, + /// `LlmError::Connectivity`。 + ConnectionFailed { attempts: u32 }, + /// `LlmError::Transport`:通道关闭 / 收尾历史落盘失败等宿主侧收场。 + TransportBroken, + /// `LlmError::StreamUnavailable`。 + StreamUnavailable, + /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 + RequestRejected { native: Option }, + /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 + UpstreamFailed { + status_code: u16, + native: Option, + }, + /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 + /// + /// 约定由 app-server 保证并有测试盯着: + /// `codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error`。 + PaidCreditsInsufficient, + /// `LlmError::EmptyResponse`。 + EmptyResponse, + /// `LlmError::Deserialize`。 + PayloadInvalid { native: Option }, +} + +impl ModelCallKind { + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 + fn is_model_repairable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } => false, + Self::ConnectionFailed { .. } => true, + // 通道关闭与收尾落盘失败:宿主自己写着"不能自动重放未确认操作",重试没有安全路径。 + Self::TransportBroken => false, + Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => !native.is_terminal(), + None => true, + }, + // 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的 + // 上游故障不值得让模型再跑一轮。 + Self::UpstreamFailed { native, .. } => match native { + Some(native) => !native.is_terminal(), + None => false, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 用户重试这一轮有没有意义。 + fn is_retryable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } | Self::ConnectionFailed { .. } => true, + Self::TransportBroken | Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => native.is_retryable(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => native.is_retryable(), + None => matches!(*status_code, 401 | 408 | 429 | 500..=599), + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余交给阶段兜底。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::ResponseTimedOut { .. } => Some("等待模型回执超时"), + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道未能建立或已断开") + } + Self::EmptyResponse => Some("模型未返回内容"), + Self::PayloadInvalid { .. } => Some("模型回执无法解析"), + Self::RequestRejected { native } => { + native.as_ref().and_then(NativeKind::public_summary) + } + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::public_summary) + .or_else(|| upstream_status_summary(*status_code)), + } + } + + /// 恢复建议:分类能直接给出动作的就给,给不出就交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足,请充值后发送“继续”"), + Self::ResponseTimedOut { .. } => { + Some("上游响应超时,请稍后重试;如持续失败请检查项目诊断") + } + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::EmptyResponse | Self::PayloadInvalid { .. } => { + Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") + } + Self::RequestRejected { native } => native.as_ref().and_then(NativeKind::recovery_hint), + Self::UpstreamFailed { + status_code, + native, + } => native + .as_ref() + .and_then(NativeKind::recovery_hint) + .or_else(|| upstream_status_recovery_hint(*status_code)), + } + } +} + +fn upstream_status_summary(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "上游服务拒绝认证(HTTP 401)", + 403 => "上游服务拒绝访问(HTTP 403)", + 408 => "上游请求超时(HTTP 408)", + 429 => "上游服务返回 HTTP 429", + 500..=599 => "上游服务暂时不可用", + _ => return None, + }) +} + +fn upstream_status_recovery_hint(status_code: u16) -> Option<&'static str> { + Some(match status_code { + 401 => "请重新登录陶泥儿后重试;如持续失败请检查项目诊断", + 403 => "请检查当前账号的上游访问权限后重试;如持续失败请检查项目诊断", + 408 => "上游请求超时,请稍后重试;如持续失败请检查项目诊断", + 429 => "上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断", + 500..=599 => "上游服务暂时不可用,请稍后重试;如持续失败请检查项目诊断", + _ => return None, + }) +} + +// ══════════════════════════════════════════════════════════════════════════════════════════ +// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列 +// ══════════════════════════════════════════════════════════════════════════════════════════ + +/// 命令边界的 typed 入队失败:变体名就是线上的分流键(`type`),前端只按它选通道,不解析文案。 +/// +/// 可达集合由 `direct_runtime::user_input::enqueue_direct_codex_turn_typed` 固定: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 +/// 放行之后发生的失败不在这张表里(见 [`TurnError`])。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum EnqueueError { + /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 + ClientTurnIdMissing, + /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 + ClientTurnIdMalformed(ClientTurnIdMalformed), + /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 + QueueFull(QueueFull), + /// 项目目录不存在或不是绝对路径。 + ProjectRootUnusable, + /// 项目权限策略拒绝了这次调用。 + PermissionRejected(PermissionRejected), + /// 用户条目 / 创建类型本身不合法。 + InputRejected(InputRejected), + /// 结构化消息既没有正文也没有任何引用。 + ContentEmpty, + /// 工程 / 凭据 / 脚手架还没准备好。 + EnvironmentNotReady(EnvironmentNotReady), + /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable(HostStateUnavailable), +} + +impl EnqueueError { + /// 命令边界要不要为这条入队失败补一份运行错误诊断。 + /// + /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; + /// 空内容、`clientTurnId` 形状、队列满、权限策略、目录不可用都是用户自己就能修的操作结果, + /// 留痕只会变成噪声。判据按变体分,不看文案。 + pub(crate) fn is_reportable(&self) -> bool { + matches!( + self, + Self::EnvironmentNotReady(_) | Self::HostStateUnavailable(_) + ) + } + + /// 写进 `.agent/runtime/errors` 与应用日志的宿主原文。只有 [`Self::is_reportable`] 为真的 + /// 变体会被调用;其余变体没有可留痕的事实,返回空串。 + pub(crate) fn diagnostic_detail(&self) -> String { + match self { + Self::EnvironmentNotReady(payload) => payload.detail.clone(), + Self::HostStateUnavailable(payload) => payload.detail.clone(), + _ => String::new(), + } + } +} + +/// `clientTurnId` 的长度约束:上下限随载荷带出去,界面不自己数一份。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ClientTurnIdMalformed { + pub(crate) min_chars: usize, + pub(crate) max_chars: usize, +} + +/// 待发消息队列上限:只落在宿主这一处(`MAX_PENDING_TURNS`)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct QueueFull { + pub(crate) limit: usize, +} + +/// 项目权限策略的原文(带被拒的点位)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct PermissionRejected { + pub(crate) policy_detail: String, +} + +/// 用户条目 / 创建类型不合法的原始解释。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct InputRejected { + pub(crate) detail: String, +} + +/// 环境 / 凭据 / 脚手架未就绪的原始解释。 +/// +/// **两张表共用**:入队侧是入队失败,回合侧是回合失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct EnvironmentNotReady { + pub(crate) detail: String, +} + +/// 宿主执行账本取不到的原始解释。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostStateUnavailable { + pub(crate) detail: String, +} + +// ══════════════════════════════════════════════════════════════════════════════════════════ +// 回合失败 + 控制流:宿主内部,放行之后发生,这一轮已经开始 +// ══════════════════════════════════════════════════════════════════════════════════════════ + +/// 回合侧 typed 错误。**不跨进程、不序列化**:它只作宿主内部的 `Err`,前端拿到的是 +/// [`TurnError::classify`] 投影出来的 [`TurnFailure`] 事件载荷。 +/// +/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:分类成 +/// [`TurnErrorClassified::ShouldContinue`],既不进失败载荷也不上报,所以前端不需要、也不会写 +/// 这两个分支。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TurnError { + /// 项目目录锚不定(符号链接 / 权限 / 目录被删):app-server 侧解析项目身份时判定。 + ProjectRootUnanchored(ProjectRootUnanchored), + /// 放行之后的工程 / 凭据 / 脚手架未就绪(例如 `turn/start` 之前连不上 app-server)。 + EnvironmentNotReady(EnvironmentNotReady), + /// 放行之后宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable(HostStateUnavailable), + /// 模型调用失败。 + ModelCallFailed(ModelCallFailed), + /// 执行通道断开。 + TransportClosed(TransportClosed), + /// 等待模型回执撞上限。 + TimedOut(TimedOut), + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted(TurnInterrupted), + /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**:交付模块用它把"还缺证据"交给 + /// 下一步。字段不跨进程,所以留在变体内联。 + ReviewRequired { detail: String }, + /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) + /// —— **控制流,不是失败**:与 [`Self::ReviewRequired`] 同一族,只是产生点在收尾阶段。 + /// 调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 + RepairRequired { detail: String }, + /// 交付阶段失败:`stage` 是 typed 的,`detail` 还是产生层交上来的字符串。 + /// + /// 名字故意起得又长又扎眼(`Super…FromStringPlusStage`):这是**待清的债**, + /// `detail` typed 化之后这一臂该改名或并入 typed 变体,别把它改漂亮。 + SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), + /// 桥:深层只拿得到字符串的错误,**连阶段都没有**。只允许出现在"这一轮已经开始"的层里, + /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。与 + /// [`Self::SuperErrorFromStringPlusStage`] 是同一笔债的两半。 + Unclassified(Unclassified), +} + +/// 项目目录锚不定的原始原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ProjectRootUnanchored { + pub(crate) cause: String, +} + +/// 模型调用失败的分类与平台层原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ModelCallFailed { + pub(crate) kind: ModelCallKind, + pub(crate) detail: String, +} + +/// 通道断开时那份宿主诊断(进程退出 / stderr 摘要)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TransportClosed { + pub(crate) diagnostic: String, +} + +/// 撞上的那条上限。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TimedOut { + pub(crate) deadline: Deadline, +} + +/// app-server 判成中断的原始说明。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TurnInterrupted { + pub(crate) detail: String, +} + +/// 已知交付阶段的失败:`stage` 是 typed enum,`detail` 是产生层原文(字符串债)。 +/// +/// 名字故意起得又长又扎眼(`Super…FromStringPlusStage`):与 [`SuperErrorEnumFromStringTyped`] +/// 同一笔债——`detail` 一天没 typed 化,它一天不配有个正常名字。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct SuperErrorFromStringPlusStage { + pub(crate) stage: FailureStage, + pub(crate) detail: String, +} + +/// 深层尚未 typed 的字符串错误:不知道发生在哪一段,只有一条宿主原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct Unclassified { + pub(crate) detail: String, +} + +/// [`TurnError::classify`] 的处置结果:调用方据此决定要不要写终态。 +/// +/// 这层替换掉原来的 `Option`:`None` 以前同时表示"没有失败"和"不是失败、要继续跑", +/// 调用方看到 `None` 只会理解成前者——控制流要么被当成"这轮没失败"糊过去,要么被当成畸形输入 +/// 硬报一条失败。两个取值各有名字,控制流带自己的说明,不再借道 `None`。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TurnErrorClassified { + /// 真失败:这一轮到此为止。载荷已投影并脱敏,可直接下发。 + ShouldStop(TurnFailure), + /// 控制流(返修 / 复核要求继续):这一轮还没结束,调用方**不得**写终态。 + // TODO string-typed: `detail` 是字符串,但当前没有任何按变体或按内容的行为分支——消费方只有 + // 返修 prompt 拼接和一条 debug_assert。先保留字符串形态;等 typed 控制流层 + // (`ReviewRequired` / `RepairRequired`)落地后再收口。 + ShouldContinue { detail: String }, +} + +impl TurnError { + /// 把这条错误投影成"该收场 / 该继续"两层:真失败带上可下发的 [`TurnFailure`] 载荷,控制流 + /// 带上自己的说明。 + /// + /// 这是 `turn.completed.failure` 的**唯一**投影点:控制流不是失败,所以它既不写终态、也不进 + /// 载荷,前端因此不需要为它写分支。载荷里的宿主原文(`detail` / `cause` / `diagnostic`)在这 + /// 一处统一脱敏 + 截断,与改造前同一条边界、同一份判据。 + pub(crate) fn classify(&self, history_root: &Path) -> TurnErrorClassified { + let redact = + |value: &str| redact_agent_runtime_error(history_root, value, FAILURE_DETAIL_MAX_CHARS); + match self { + Self::ProjectRootUnanchored(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored { + cause: redact(&payload.cause), + }), + ), + Self::EnvironmentNotReady(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::EnvironmentNotReady(EnvironmentNotReady { + detail: redact(&payload.detail), + }), + ), + Self::HostStateUnavailable(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::HostStateUnavailable(HostStateUnavailable { + detail: redact(&payload.detail), + }), + ), + Self::ModelCallFailed(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::ModelCallFailed(ModelCallFailed { + kind: payload.kind.clone(), + detail: redact(&payload.detail), + })) + } + Self::TransportClosed(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TransportClosed(TransportClosed { + diagnostic: redact(&payload.diagnostic), + })) + } + Self::TimedOut(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(payload.clone())) + } + Self::TurnInterrupted(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::TurnInterrupted(TurnInterrupted { + detail: redact(&payload.detail), + })) + } + Self::SuperErrorFromStringPlusStage(payload) => TurnErrorClassified::ShouldStop( + TurnFailure::SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage { + stage: payload.stage, + detail: redact(&payload.detail), + }), + ), + Self::Unclassified(payload) => { + TurnErrorClassified::ShouldStop(TurnFailure::Unclassified(Unclassified { + detail: redact(&payload.detail), + })) + } + // 控制流:这一轮还没结束,不是失败。 + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { + TurnErrorClassified::ShouldContinue { + detail: detail.clone(), + } + } + } + } + + /// 诊断 / 日志用的宿主原文。**不上屏**:界面文案由前端按变体拼,这里只喂 + /// `.agent/runtime/errors`、应用日志与错误上报池。 + pub(crate) fn diagnostic_detail(&self) -> String { + match self { + Self::ProjectRootUnanchored(payload) => { + format!("无法锚定 Direct 调用项目目录:{}", payload.cause) + } + Self::EnvironmentNotReady(payload) => payload.detail.clone(), + Self::HostStateUnavailable(payload) => payload.detail.clone(), + Self::ModelCallFailed(payload) => payload.detail.clone(), + Self::TransportClosed(payload) => { + format!( + "执行通道已断开,不能自动重放未确认操作:{}", + payload.diagnostic + ) + } + Self::TimedOut(payload) => payload.deadline.message().to_string(), + Self::TurnInterrupted(payload) => payload.detail.clone(), + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => detail.clone(), + Self::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), + Self::Unclassified(payload) => payload.detail.clone(), + } + } + + /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 + pub(crate) fn turn_failure_stage(&self) -> FailureStage { + match self { + Self::SuperErrorFromStringPlusStage(payload) => payload.stage, + _ => FailureStage::CodeGeneration, + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 + pub(crate) fn is_model_repairable(&self) -> bool { + match self { + Self::ModelCallFailed(payload) => payload.kind.is_model_repairable(), + // 阶段失败 / 桥变体:**认出是哪一类就拦**(产生层还没 typed 出口的深层事实才继续 + // 反馈)。已归类的都是模型改不动的事实——凭据 / 权限 / 额度 / 历史一致性与契约变化, + // 把同一份输入再跑一轮只会拿到同一结论;旧的字面量判据也是这个口径。 + Self::SuperErrorFromStringPlusStage(payload) => { + SuperErrorEnumFromStringTyped::classify(&payload.detail).is_none() + } + Self::Unclassified(payload) => { + SuperErrorEnumFromStringTyped::classify(&payload.detail).is_none() + } + _ => false, + } + } + + /// 用户重试这一轮有没有意义。 + pub(crate) fn is_retryable(&self) -> bool { + match self { + Self::ModelCallFailed(payload) => payload.kind.is_retryable(), + Self::TransportClosed(_) => false, + // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 + Self::TimedOut(_) | Self::TurnInterrupted(_) => true, + Self::SuperErrorFromStringPlusStage(payload) => { + SuperErrorEnumFromStringTyped::classify(&payload.detail) + .is_none_or(SuperErrorEnumFromStringTyped::is_retryable) + } + Self::Unclassified(payload) => SuperErrorEnumFromStringTyped::classify(&payload.detail) + .is_none_or(SuperErrorEnumFromStringTyped::is_retryable), + _ => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 + pub(crate) fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed(payload) => payload.kind.public_summary(), + Self::SuperErrorFromStringPlusStage(payload) => { + SuperErrorEnumFromStringTyped::classify(&payload.detail) + .and_then(SuperErrorEnumFromStringTyped::public_summary) + } + Self::Unclassified(payload) => SuperErrorEnumFromStringTyped::classify(&payload.detail) + .and_then(SuperErrorEnumFromStringTyped::public_summary), + _ => None, + } + } + + /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 + pub(crate) fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed(payload) => payload.kind.recovery_hint(), + Self::TransportClosed(_) => { + Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::TimedOut(_) => { + Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") + } + Self::TurnInterrupted(_) => { + Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") + } + Self::SuperErrorFromStringPlusStage(payload) => { + stage_recovery_hint(payload.stage, &payload.detail) + } + // 桥变体没有阶段可依,按回合主体的默认段给建议。 + Self::Unclassified(payload) => { + stage_recovery_hint(FailureStage::CodeGeneration, &payload.detail) + } + _ => None, + } + } + + /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** + /// + /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 + /// [`NativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 + pub(crate) fn from_model_call(error: &LlmError) -> Self { + let detail = error.to_string(); + let kind = match error { + LlmError::Timeout { attempts } => ModelCallKind::ResponseTimedOut { + attempts: *attempts, + }, + LlmError::Connectivity { attempts, .. } => ModelCallKind::ConnectionFailed { + attempts: *attempts, + }, + // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 + LlmError::Transport(_) => ModelCallKind::TransportBroken, + LlmError::StreamUnavailable => ModelCallKind::StreamUnavailable, + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { + ModelCallKind::RequestRejected { + native: native_kind(&detail), + } + } + LlmError::Upstream { status_code, .. } if *status_code == 409 => { + ModelCallKind::PaidCreditsInsufficient + } + LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { + status_code: *status_code, + native: native_kind(&detail), + }, + LlmError::EmptyResponse => ModelCallKind::EmptyResponse, + LlmError::Deserialize(_) => ModelCallKind::PayloadInvalid { + native: native_kind(&detail), + }, + }; + Self::ModelCallFailed(ModelCallFailed { kind, detail }) + } + + /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 + pub(crate) fn turn_failed(stage: FailureStage, detail: impl Into) -> Self { + Self::SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage { + stage, + detail: detail.into(), + }) + } + + /// 工程 / 凭据 / 脚手架未就绪:与入队侧共用同一个载荷,只是发生在放行之后。 + pub(crate) fn environment_not_ready(detail: impl Into) -> Self { + Self::EnvironmentNotReady(EnvironmentNotReady { + detail: detail.into(), + }) + } +} + +/// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。 +/// +/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 +impl From for TurnError { + fn from(detail: String) -> Self { + Self::Unclassified(Unclassified { detail }) + } +} + +impl From<&str> for TurnError { + fn from(detail: &str) -> Self { + Self::Unclassified(Unclassified { + detail: detail.to_string(), + }) + } +} + +/// 从原因文本里读出 app-server 写下的原生失败分类。 +/// +/// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 +fn native_kind(detail: &str) -> Option { + let rest = detail.split_once(NATIVE_KIND_PREFIX)?.1; + let id = rest + .split(|character: char| character.is_whitespace()) + .next() + .unwrap_or_default(); + if id.is_empty() { + return None; + } + Some(NativeKind::from_id(id)) +} + +/// 深层失败事实**表**:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 +/// +/// 名字故意起得又长又扎眼(`Super…FromStringTyped`):这是**待清的债**,不是好名字。 +/// 别把它改漂亮——要么按文件顶部 TODO 让上游产出 typed 变体后整张表删掉,要么什么都别做。 +/// +/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 +/// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SuperErrorEnumFromStringTyped { + /// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。 + PaidCreditsInsufficient, + /// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。 + CredentialStorageUnprepared, + /// 本机私有凭据已创建但没保存住(`assets` 的私有凭据存储)。 + CredentialNotPersisted, + /// 本机还没有直连用的开发者 Key(`assets`)。 + LocalDeveloperKeyMissing, + /// 登录态失效 / 上游 401(`assets` / 项目权限读取)。 + AuthenticationRejected, + /// 账号没有访问该资源的权限 / 上游 403。 + PermissionDenied, + /// 本机私有凭据不可用(笼统的一条)。 + CredentialsUnavailable, + /// 项目对话历史注入载荷超限(`codex_app_server` 的历史注入前置校验)。 + HistoryInjectionOversize, + /// 项目对话历史存在本版本无法识别的记录(`direct_project_history`)。 + HistoryShapeUnsupported, + /// 另一个进程正持有历史追加锁(`project` 的追加锁)。 + HistoryContention, + /// 项目写锁争用(`project` 的写锁)。 + ProjectWriteLockContention, + /// 历史图集身份不唯一 / 不匹配 / 无可见像素(`direct_runtime` 的图集恢复前置校验)。 + ArtIdentityRejected, + /// 交付合同在恢复期间发生变化(`direct_runtime` 的图集恢复)。 + ContractChanged, + /// 本轮执行/返修预算已耗尽(`direct_execution`)。 + ValidationBudgetExhausted, + /// 同一输入的验证已受理(`direct_execution`)。 + ValidationAlreadyRunning, + /// 试玩次数上限(`direct_validation`)。 + PlaytestAttemptLimitExceeded, + /// 工具参数不合法(`direct_tool_bridge`)。 + ToolArgumentsInvalid, + /// 本轮被取消(用户终止 / 上游取消)。 + Cancelled, +} + +impl SuperErrorEnumFromStringTyped { + fn classify(detail: &str) -> Option { + let normalized = detail.to_ascii_lowercase(); + let contains = |marker: &str| { + if marker.chars().any(char::is_uppercase) { + detail.contains(marker) + } else { + normalized.contains(marker) + } + }; + // 顺序即优先级:更具体的事实先判,笼统的放后面。 + const CANDIDATES: &[(SuperErrorEnumFromStringTyped, &[&str])] = &[ + ( + SuperErrorEnumFromStringTyped::PaidCreditsInsufficient, + &[ + "泥点余额不足", + "可消费泥点不足", + "kind=mud-points-insufficient", + "insufficient_mud_points", + "insufficient-mud-points", + ], + ), + ( + SuperErrorEnumFromStringTyped::CredentialStorageUnprepared, + &["private-external-editor-credential-storage-preparation-failed"], + ), + ( + SuperErrorEnumFromStringTyped::CredentialNotPersisted, + &["private-external-editor-credential-persistence-failed"], + ), + ( + SuperErrorEnumFromStringTyped::LocalDeveloperKeyMissing, + &["本机陶泥儿开发者 Key"], + ), + ( + SuperErrorEnumFromStringTyped::AuthenticationRejected, + &["authentication-required", "unauthorized", "http 401"], + ), + ( + SuperErrorEnumFromStringTyped::PermissionDenied, + &["permission-denied", "http 403"], + ), + ( + SuperErrorEnumFromStringTyped::HistoryInjectionOversize, + &["历史注入载荷超过单行上限"], + ), + ( + SuperErrorEnumFromStringTyped::HistoryShapeUnsupported, + &[ + "DirectProject 历史记录类型无效", + "DirectProject 历史记录缺少 payload", + "解析 DirectProject 历史失败", + ], + ), + ( + SuperErrorEnumFromStringTyped::ProjectWriteLockContention, + &[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX], + ), + ( + SuperErrorEnumFromStringTyped::HistoryContention, + &[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER], + ), + ( + SuperErrorEnumFromStringTyped::ArtIdentityRejected, + &[ + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + ], + ), + ( + SuperErrorEnumFromStringTyped::ContractChanged, + &["合同发生变化"], + ), + ( + SuperErrorEnumFromStringTyped::ValidationBudgetExhausted, + &["validation-budget-exhausted"], + ), + ( + SuperErrorEnumFromStringTyped::ValidationAlreadyRunning, + &["validation-already-running"], + ), + ( + SuperErrorEnumFromStringTyped::PlaytestAttemptLimitExceeded, + &["playtest-attempt-limit-exceeded"], + ), + ( + SuperErrorEnumFromStringTyped::ToolArgumentsInvalid, + &["工具参数"], + ), + (SuperErrorEnumFromStringTyped::Cancelled, &["取消"]), + ( + SuperErrorEnumFromStringTyped::CredentialsUnavailable, + &["credential", "凭据"], + ), + ]; + CANDIDATES + .iter() + .find(|(_, markers)| markers.iter().any(|marker| contains(marker))) + .map(|(fact, _)| *fact) + } + + /// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。 + fn is_retryable(self) -> bool { + match self { + Self::PaidCreditsInsufficient + | Self::CredentialStorageUnprepared + | Self::CredentialNotPersisted + | Self::HistoryInjectionOversize + | Self::HistoryShapeUnsupported + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded => false, + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ToolArgumentsInvalid + | Self::Cancelled => true, + } + } + + /// 给用户看的稳定摘要:能一句话说清"哪里不对"的才有。 + fn public_summary(self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::CredentialStorageUnprepared => { + Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据") + } + Self::CredentialNotPersisted => Some("本机开发者凭据已创建但未能安全保存"), + Self::HistoryInjectionOversize => Some("项目对话历史有单条记录超过注入上限"), + Self::LocalDeveloperKeyMissing + | Self::AuthenticationRejected + | Self::PermissionDenied + | Self::CredentialsUnavailable + | Self::HistoryShapeUnsupported + | Self::HistoryContention + | Self::ProjectWriteLockContention + | Self::ArtIdentityRejected + | Self::ContractChanged + | Self::ValidationBudgetExhausted + | Self::ValidationAlreadyRunning + | Self::PlaytestAttemptLimitExceeded + | Self::ToolArgumentsInvalid + | Self::Cancelled => None, + } + } + + /// 恢复建议:每条事实对应一个用户能做的动作。 + fn recovery_hint(self) -> &'static str { + match self { + Self::PaidCreditsInsufficient => "泥点余额不足,请充值后发送“继续”", + Self::CredentialStorageUnprepared => { + "请检查当前 Windows 用户对本机私有凭据目录的权限后重试" + } + Self::CredentialNotPersisted => { + "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试" + } + Self::LocalDeveloperKeyMissing => { + "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key" + } + Self::AuthenticationRejected => "登录态可能已失效,请重新登录陶泥儿后重试", + Self::PermissionDenied => { + "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试" + } + Self::CredentialsUnavailable => "请检查本机开发者凭据后重试", + Self::HistoryInjectionOversize => { + "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求" + } + Self::HistoryShapeUnsupported => { + "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + } + Self::HistoryContention => { + "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" + } + Self::ProjectWriteLockContention => "当前项目仍有写入正在结束,请稍后再次发送该需求", + Self::ArtIdentityRejected => { + "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集" + } + Self::ContractChanged => "本轮产物合同在恢复期间发生变化,请重新发送该需求", + Self::ValidationBudgetExhausted => { + "本轮验证预算已耗尽,请发送“继续”开始下一批返修" + } + Self::ValidationAlreadyRunning => "同一输入的验证已受理,请等待原执行结束后再试", + Self::PlaytestAttemptLimitExceeded => { + "试玩次数已达上限,请按项目诊断修复后再次发送需求" + } + Self::ToolArgumentsInvalid => "工具参数不合法,请重试;如持续失败请检查项目诊断", + Self::Cancelled => "本轮已取消,请重新发送该需求", + } + } +} + +/// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 +fn stage_recovery_hint(stage: FailureStage, detail: &str) -> Option<&'static str> { + if let Some(fact) = SuperErrorEnumFromStringTyped::classify(detail) { + return Some(fact.recovery_hint()); + } + Some(match stage { + FailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + FailureStage::CodeGeneration => "Codex 未完成本轮代码修改,请检查运行时配置后重试", + FailureStage::BrowserValidation => "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求", + FailureStage::VersionRegistration => "产物尚未安全登记为版本,请检查项目目录后重试", + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 只有宿主 / 环境事实值得在命令边界补诊断:用户的正常操作结果不补。 + #[test] + fn only_host_and_environment_enqueue_rejections_are_reportable() { + assert!(EnqueueError::EnvironmentNotReady(EnvironmentNotReady { + detail: "Codex app-server 启动失败".into(), + }) + .is_reportable()); + assert!(EnqueueError::HostStateUnavailable(HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".into(), + }) + .is_reportable()); + assert!(!EnqueueError::ContentEmpty.is_reportable()); + assert!(!EnqueueError::ProjectRootUnusable.is_reportable()); + assert!(!EnqueueError::ClientTurnIdMissing.is_reportable()); + assert!(!EnqueueError::PermissionRejected(PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + }) + .is_reportable()); + // 可留痕的变体给出宿主原文;其余变体没有可留痕的事实。 + assert_eq!( + EnqueueError::EnvironmentNotReady(EnvironmentNotReady { + detail: "启动失败".into(), + }) + .diagnostic_detail(), + "启动失败" + ); + assert_eq!( + EnqueueError::ContentEmpty.diagnostic_detail(), + String::new() + ); + } + + /// `classify` 是失败载荷的唯一投影点:控制流不是失败,落进 `ShouldContinue`。 + #[test] + fn classify_projects_failures_and_separates_control_flow() { + let root = Path::new("/tmp/direct-turn-error-test"); + let timed_out = TurnError::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + }); + assert_eq!( + timed_out.classify(root), + TurnErrorClassified::ShouldStop(TurnFailure::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + })) + ); + assert_eq!( + TurnError::ReviewRequired { + detail: "还缺证据".into() + } + .classify(root), + TurnErrorClassified::ShouldContinue { + detail: "还缺证据".into() + } + ); + assert_eq!( + TurnError::RepairRequired { + detail: "继续返修".into() + } + .classify(root), + TurnErrorClassified::ShouldContinue { + detail: "继续返修".into() + } + ); + } + + /// 失败载荷的线上形状:`type` 判别键 + 驼峰字段 + 变体裸名载荷,这是前端唯一读的协议。 + /// 改变体名或字段名就是改协议,必须在这条用例上先失败。 + #[test] + fn failure_payload_wire_shape_is_stable() { + let payload = TurnFailure::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + }); + assert_eq!( + serde_json::to_value(&payload).expect("serialize"), + serde_json::json!({ "type": "timedOut", "deadline": "turn-hard-limit" }) + ); + assert_eq!( + serde_json::from_value::(serde_json::json!({ + "type": "timedOut", + "deadline": "turn-hard-limit" + })) + .expect("round trip"), + payload + ); + + let payload = TurnFailure::SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage { + stage: FailureStage::BrowserValidation, + detail: "试玩失败".into(), + }); + assert_eq!( + serde_json::to_value(&payload).expect("serialize"), + serde_json::json!({ + "type": "superErrorFromStringPlusStage", + "stage": "browser-validation", + "detail": "试玩失败" + }) + ); + + // 没有字段可读的变体只带判别键。 + assert_eq!( + serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::json!({ "type": "hostDropped" }) + ); + } + + /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 + #[test] + fn model_call_kind_matches_the_previous_llm_error_classification() { + let cases = [ + ( + LlmError::Timeout { attempts: 3 }, + ModelCallKind::ResponseTimedOut { attempts: 3 }, + ), + ( + LlmError::InvalidConfig("missing key".into()), + ModelCallKind::RequestRejected { native: None }, + ), + ( + LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), + ModelCallKind::RequestRejected { + native: Some(NativeKind::ContextWindowExceeded), + }, + ), + ( + LlmError::Connectivity { + attempts: 2, + message: "Codex app-server 连接失败".into(), + }, + ModelCallKind::ConnectionFailed { attempts: 2 }, + ), + ( + LlmError::Transport("DirectProject 收尾历史失败".into()), + ModelCallKind::TransportBroken, + ), + ( + LlmError::StreamUnavailable, + ModelCallKind::StreamUnavailable, + ), + ( + LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }, + ModelCallKind::UpstreamFailed { + status_code: 502, + native: None, + }, + ), + (LlmError::EmptyResponse, ModelCallKind::EmptyResponse), + ( + LlmError::Deserialize("bad payload".into()), + ModelCallKind::PayloadInvalid { native: None }, + ), + ]; + for (error, expected) in cases { + let projected = TurnError::from_model_call(&error); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, expected, "{error:?}") + } + other => panic!("expected a model call failure, got {other:?}"), + } + } + } + + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 + #[test] + fn native_kind_is_read_from_the_structured_prefix_only() { + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), + )); + match projected { + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { + native: Some(NativeKind::ContextWindowExceeded) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:some-future-kind".into(), + )); + match projected { + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { + native: Some(NativeKind::Other { + kind: "some-future-kind".into() + }) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( + "Codex app-server turn 已中断".into(), + )); + match projected { + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { native: None } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 上游 409 是平台约定的泥点余额不足,进专属分类。 + #[test] + fn upstream_payment_refusal_is_its_own_kind() { + let projected = TurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); + match &projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, ModelCallKind::PaidCreditsInsufficient) + } + other => panic!("expected a model call failure, got {other:?}"), + } + assert_eq!(projected.public_summary(), Some("泥点余额不足")); + assert_eq!( + projected.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert!(!projected.is_retryable()); + assert!(!projected.is_model_repairable()); + } + + #[test] + fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { + let rate_limited = TurnError::from_model_call(&LlmError::Upstream { + status_code: 429, + message: "Claude Code 返回失败终态:API Error: Request rejected (429)".into(), + }); + assert_eq!(rate_limited.public_summary(), Some("上游服务返回 HTTP 429")); + assert_eq!( + rate_limited.recovery_hint(), + Some("上游服务暂时繁忙(HTTP 429),请稍后重试;如持续失败请检查项目诊断") + ); + assert!(rate_limited.is_retryable()); + assert!(!rate_limited.is_model_repairable()); + + let unauthorized = TurnError::from_model_call(&LlmError::Upstream { + status_code: 401, + message: "Claude Code 返回失败终态:HTTP 401".into(), + }); + assert_eq!( + unauthorized.public_summary(), + Some("上游服务拒绝认证(HTTP 401)") + ); + assert!(unauthorized.is_retryable()); + } + + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 + #[test] + fn terminal_native_kinds_are_not_fed_back_to_the_model() { + let terminal = TurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + assert!(!terminal.is_model_repairable()); + let repairable = TurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into(), + )); + assert!(repairable.is_model_repairable()); + // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 + assert!(TurnError::from_model_call(&LlmError::Connectivity { + attempts: 2, + message: "连接失败".into(), + }) + .is_model_repairable()); + assert!(!TurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into() + )) + .is_model_repairable()); + assert!( + !TurnError::from_model_call(&LlmError::Timeout { attempts: 1 }).is_model_repairable() + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 3762251ca..246c484ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -4,7 +4,7 @@ mod model; mod validation; mod wire; -pub(crate) use model::DirectCodexUserItem; +pub(crate) use model::UserItem; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 0fbd38640..980b429df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -5,31 +5,31 @@ use ts_rs::TS; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserItem { +pub(crate) enum UserItem { #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), + Message(UserMessageItem), } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, +pub(crate) struct UserMessageItem { + pub(crate) role: UserRole, + pub(crate) content: Vec, pub(crate) id: String, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserRole { +pub(crate) enum UserRole { User, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserContentPart { +pub(crate) enum UserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] @@ -44,16 +44,16 @@ pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "agc_skill_reference")] AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + AgcRuntimeRegionReference(UserRuntimeRegionPart), /// Uploaded project attachment kept inline in canonical content. #[serde(rename = "agc_attachment_reference")] - AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), + AgcAttachmentReference(UserAttachmentReferencePart), } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserAttachmentReferencePart { +pub(crate) struct UserAttachmentReferencePart { pub(crate) name: String, pub(crate) media_type: String, #[ts(type = "number")] @@ -65,7 +65,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { +pub(crate) struct UserRuntimeRegionPart { pub(crate) label: String, #[serde(default)] pub(crate) run_id: Option, @@ -92,9 +92,9 @@ mod tests { #[test] fn resource_reference_serializes_with_only_camel_case_resource_id() { - let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { - role: DirectCodexUserRole::User, - content: vec![DirectCodexUserContentPart::AgcResourceReference { + let item = UserItem::Message(UserMessageItem { + role: UserRole::User, + content: vec![UserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), resolved_text: None, }], @@ -116,7 +116,7 @@ mod tests { #[test] fn resource_reference_rejects_extra_identity_fields() { - let error = serde_json::from_value::(json!({ + let error = serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{ @@ -133,7 +133,7 @@ mod tests { /// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。 #[test] fn resource_reference_without_resolved_text_still_parses() { - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "content": [{ @@ -143,9 +143,9 @@ mod tests { "id": "turn-1" })) .expect("legacy resource reference must keep parsing"); - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; match &message.content[0] { - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -158,7 +158,7 @@ mod tests { #[test] fn skill_reference_serializes_with_only_the_stable_name() { - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "content": [{"type": "agc_skill_reference", "name": "agc-web-game-development"}], @@ -172,7 +172,7 @@ mod tests { // canonical part 不接受正文、路径或凭据类附加字段:它们只可能来自宿主私密状态。 for forbidden in ["path", "body", "content", "token", "apiKey"] { - let error = serde_json::from_value::(json!({ + let error = serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{ @@ -192,7 +192,7 @@ mod tests { #[test] fn unknown_content_part_fails_closed() { - serde_json::from_value::(json!({ + serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{"type": "future_part", "value": "x"}], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index a14d93b63..c293f2982 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -1,7 +1,4 @@ -use super::model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, - DirectCodexUserRuntimeRegionPart, -}; +use super::model::{UserContentPart, UserItem, UserRole, UserRuntimeRegionPart}; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS, @@ -16,10 +13,10 @@ pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32; pub(crate) fn validate_direct_codex_user_item( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { + let UserItem::Message(message) = item; + if !matches!(message.role, UserRole::User) { return Err("DirectProject 只接受 user message item".to_string()); } if message.id.trim().is_empty() { @@ -36,12 +33,12 @@ pub(crate) fn validate_direct_codex_user_item( let mut skill_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { .. } => {} - DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => { + UserContentPart::InputText { .. } => {} + UserContentPart::AgcResourceReference { resource_id, .. } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { skill_count = skill_count.saturating_add(1); if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES { return Err(format!( @@ -61,11 +58,11 @@ pub(crate) fn validate_direct_codex_user_item( return Err("引用的 Skill 名称无效,请移除后重新选择".to_string()); } } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { attachment_count = attachment_count.saturating_add(1); if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS { return Err(format!( @@ -112,9 +109,9 @@ pub(crate) fn validate_direct_codex_user_item( } /// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 -pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { +pub(crate) fn content_has_meaningful_input(content: &[UserContentPart]) -> bool { content.iter().any(|part| match part { - DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + UserContentPart::InputText { text } => !text.trim().is_empty(), _ => true, }) } @@ -142,7 +139,7 @@ pub(crate) fn validate_resource_id_and_manifest( fn validate_runtime_region_reference( manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, + reference: &UserRuntimeRegionPart, ) -> Result<(), String> { if reference.label.trim().is_empty() { return Err("运行画面区域缺少名称".to_string()); @@ -164,11 +161,11 @@ mod tests { content_has_meaningful_input, validate_direct_codex_user_item, MAX_DIRECT_CODEX_SKILL_REFERENCES, }; - use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + use crate::agent::direct_codex_user_item::model::UserContentPart; use serde_json::json; - fn input_text(text: &str) -> DirectCodexUserContentPart { - DirectCodexUserContentPart::InputText { + fn input_text(text: &str) -> UserContentPart { + UserContentPart::InputText { text: text.to_string(), } } @@ -200,7 +197,7 @@ mod tests { #[test] fn non_text_parts_always_count_as_input() { assert!(content_has_meaningful_input(&[ - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), resolved_text: None, }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index ca0837eee..dab0e3ca1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,5 @@ use super::model::{ - DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, - DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart, + UserAttachmentReferencePart, UserContentPart, UserItem, UserMessageItem, UserRuntimeRegionPart, }; use super::validation::validate_direct_codex_user_item; use crate::agent::{ @@ -15,21 +14,21 @@ use serde_json::Value; use std::path::Path; /// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的 -/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。 +/// [`UserContentPart::AgcResourceReference::resolved_text`] → 判空。 /// /// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口: /// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest, /// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。 pub(crate) fn freeze_direct_codex_user_item( root: &Path, - item: &DirectCodexUserItem, -) -> Result { + item: &UserItem, +) -> Result { let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut content = Vec::with_capacity(message.content.len()); for part in &message.content { match part { - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -42,7 +41,7 @@ pub(crate) fn freeze_direct_codex_user_item( resource_id, )?), }; - content.push(DirectCodexUserContentPart::AgcResourceReference { + content.push(UserContentPart::AgcResourceReference { resource_id: resource_id.clone(), resolved_text, }); @@ -50,7 +49,7 @@ pub(crate) fn freeze_direct_codex_user_item( other => content.push(other.clone()), } } - let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + let frozen = UserItem::Message(UserMessageItem { role: message.role.clone(), content, id: message.id.clone(), @@ -75,7 +74,7 @@ pub(crate) fn direct_codex_user_item_to_response_item( } return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); } - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + let canonical: UserItem = serde_json::from_value(item.clone()) .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; let content = direct_codex_user_item_to_response_content(root, &canonical)?; let mut projected = serde_json::json!({ @@ -91,7 +90,7 @@ pub(crate) fn direct_codex_user_item_to_response_item( fn direct_codex_user_item_to_response_content( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result, String> { let Value::Array(input) = direct_codex_user_item_to_wire_input(root, item)? else { return Err("DirectProject user item wire content 不是数组".to_string()); @@ -125,7 +124,7 @@ fn resource_reference_summary( )) } -fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String { +fn runtime_region_summary(reference: &UserRuntimeRegionPart) -> String { let resources = reference .resource_ids .iter() @@ -153,7 +152,7 @@ fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> Strin /// /// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、 /// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。 -fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String { +fn attachment_reference_summary(reference: &UserAttachmentReferencePart) -> String { let name = sanitize_attachment_name(&reference.name); let media_type = sanitize_attachment_media_type(&reference.media_type); let mut summary = format!( @@ -172,16 +171,16 @@ fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePa /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result { // validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。 let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::InputText { text } => text.clone(), + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => match resolved_text { @@ -190,13 +189,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input( // 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。 None => resource_reference_summary(&manifest, resource_id)?, }, - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { format!("${}", name.trim()) } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { runtime_region_summary(reference) } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { attachment_reference_summary(reference) } }; @@ -207,18 +206,18 @@ pub(crate) fn direct_codex_user_item_to_wire_input( pub(crate) fn direct_codex_user_item_to_codex_turn_input( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, skill_roots: &[std::path::PathBuf], ) -> Result { let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { + UserContentPart::InputText { text } => { input.push(serde_json::json!({ "type": "text", "text": text })); } - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -230,7 +229,7 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( }, })); } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { let name = name.trim(); let path = skill_roots .iter() @@ -243,13 +242,13 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( "path": path, })); } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { input.push(serde_json::json!({ "type": "text", "text": runtime_region_summary(reference), })); } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { input.push(serde_json::json!({ "type": "text", "text": attachment_reference_summary(reference), @@ -266,24 +265,24 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( /// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算, /// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目 /// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。 -pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String { - let DirectCodexUserItem::Message(message) = item; +pub(crate) fn direct_codex_user_item_to_prompt(item: &UserItem) -> String { + let UserItem::Message(message) = item; let mut prompt = String::new(); for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => prompt.push_str(text), - DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + UserContentPart::InputText { text } => prompt.push_str(text), + UserContentPart::AgcResourceReference { resolved_text, .. } => { if let Some(text) = resolved_text { prompt.push_str(text); } } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { prompt.push_str(&format!("${}", name.trim())); } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { prompt.push_str(&runtime_region_summary(reference)); } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { prompt.push_str(&attachment_reference_summary(reference)); } } @@ -351,7 +350,7 @@ mod tests { direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, freeze_direct_codex_user_item, validate_direct_codex_user_item, }; - use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; + use crate::agent::direct_codex_user_item::model::UserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; use shared_contracts::game_creation_app::{ @@ -437,7 +436,7 @@ mod tests { #[test] fn ui_design_doc_reference_appends_generated_code_context() { let (project, asset_id) = ui_design_doc_fixture(true); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -453,9 +452,9 @@ mod tests { "{prompt}" ); // 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。 - let super::DirectCodexUserItem::Message(message) = &frozen; + let super::UserItem::Message(message) = &frozen; match &message.content[0] { - super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + super::UserContentPart::AgcResourceReference { resolved_text, .. } => { assert_eq!(resolved_text.as_deref(), Some(prompt.as_str())); } other => panic!("expected a frozen resource reference, got {other:?}"), @@ -488,7 +487,7 @@ mod tests { #[test] fn ui_design_generation_failure_keeps_reference_and_reports_error() { let (project, asset_id) = ui_design_doc_fixture(false); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -506,7 +505,7 @@ mod tests { GameCreationAppAssetKind::Character, "image/png", ); - let item: super::DirectCodexUserItem = serde_json::from_value(json!({ + let item: super::UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-multi-1:user", @@ -547,7 +546,7 @@ mod tests { GameCreationAppAssetKind::Character, "image/png", ); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -774,8 +773,7 @@ mod tests { {"type": "agc_skill_reference", "name": "missing-skill"} ] }); - let user_item: DirectCodexUserItem = - serde_json::from_value(item).expect("parse canonical item"); + let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item"); // 已启用目录里没有这个 Skill:转换必须在启动回合前失败关闭, // 不能把不可用的引用降级成正文放行。 let error = direct_codex_user_item_to_codex_turn_input( @@ -804,8 +802,7 @@ mod tests { {"type": "input_text", "text": "然后创建菜单"} ] }); - let user_item: DirectCodexUserItem = - serde_json::from_value(item).expect("parse canonical item"); + let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item"); let input = direct_codex_user_item_to_codex_turn_input(root.path(), &user_item, &[skill_root]) .expect("available skill should convert"); @@ -884,7 +881,7 @@ mod tests { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-1:user", @@ -908,7 +905,7 @@ mod tests { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-1:user", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index d4a7a8432..a65bf0679 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -698,12 +698,12 @@ pub(super) async fn finish_sealing( /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 /// -/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 +/// 返修要求是**控制流**([`TurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 /// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。 pub(super) async fn review_reply( root: &Path, session: &Arc, -) -> Result, DirectTurnError> { +) -> Result, TurnError> { if let Some(report) = terminal_report(session) { return Ok(Some(report)); } @@ -754,7 +754,7 @@ pub(super) async fn review_reply( .map_err(|_| "delivery-review-worker-exited")??; return Ok(Some(report)); } - Err(DirectTurnError::ReviewRequired { + Err(TurnError::ReviewRequired { detail: format!("delivery-review-required: {detail}"), }) } @@ -919,7 +919,7 @@ mod tests { for _ in 0..2 { assert!(matches!( review_reply(new_game.path(), &required).await.unwrap_err(), - DirectTurnError::ReviewRequired { .. } + TurnError::ReviewRequired { .. } )); } assert!(review_reply(new_game.path(), &required) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index b9f351829..dc71492c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1939,45 +1939,135 @@ fn direct_taonier_art_generation_outcome( } /// 回合级失败最多反馈给模型几次:工具 / 构建 / 试玩类失败继续修,基础设施类失败在 -/// [`DirectTurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 +/// [`TurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3; fn direct_codex_error_feedback_prompt(error: &str) -> String { format!(prompt_text!("direct.errorFeedback"), error = error) } +/// 回合失败诊断的默认恢复建议。 +const DIRECT_CODEX_DEFAULT_RECOVERY_HINT: &str = "请重试;如持续失败请检查项目诊断"; + /// 把 typed 失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝** -/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 +/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的入队失败** +/// ([`EnqueueError::is_reportable`],宿主 / 环境事实)。 /// -/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, -/// 事件载荷与横幅共用同一份,命令边界也只序列化它一次。诊断文件的引用**不进**这份文案: -/// 界面不再展开详情,线索只留在宿主侧(`.agent/runtime/errors`、应用日志与错误上报池)。 +/// 返回值是 `direct-codex-failure:v2 ...` 这行宿主收口文本:它只进 `.agent/runtime/errors` +/// 与应用日志、错误上报池,**不再下发给前端**(前端按 typed 变体自己拼用户可见文案)。 +/// 调用方现在一律丢弃这个返回值:失败载荷原样透传 typed 变体,收口文案不再当 `detail`, +/// 返回值只剩诊断测试在断言。 pub(crate) fn record_direct_codex_failure( root: &Path, - failure: &DirectTurnError, + failure: &TurnError, client_turn_id: Option<&str>, ) -> String { - let detail = failure.to_string(); - let stage = failure.turn_failure_stage(); + let detail = failure.diagnostic_detail(); let summary = failure .public_summary() .map(str::to_string) .unwrap_or_else(|| redact_agent_runtime_error(root, &detail, 320)); + // `TurnError` 不跨进程、不序列化;进事件的是它投影出的线上载荷(与下发前端的同一份)。 + let typed_error = match failure.classify(root) { + TurnErrorClassified::ShouldStop(payload) => { + serde_json::to_value(&payload).unwrap_or(serde_json::Value::Null) + } + // 控制流不是失败:不该走到真失败诊断,真漏进来也不冒充一份 typed 失败载荷。 + TurnErrorClassified::ShouldContinue { .. } => serde_json::Value::Null, + }; + record_direct_codex_failure_facts( + root, + client_turn_id, + failure.turn_failure_stage().id(), + direct_codex_error_code(failure), + failure.is_retryable(), + failure + .recovery_hint() + .unwrap_or(DIRECT_CODEX_DEFAULT_RECOVERY_HINT), + detail, + summary, + typed_error, + ) +} + +/// 入队失败边界的诊断:入队侧没有阶段、也没有恢复建议,只留宿主原文。 +pub(crate) fn record_direct_codex_enqueue_failure( + root: &Path, + failure: &EnqueueError, + client_turn_id: Option<&str>, +) -> String { + let detail = failure.diagnostic_detail(); + let summary = redact_agent_runtime_error(root, &detail, 320); + record_direct_codex_failure_facts( + root, + client_turn_id, + FailureStage::CodeGeneration.id(), + classify_direct_codex_error(&detail), + false, + DIRECT_CODEX_DEFAULT_RECOVERY_HINT, + detail, + summary, + serde_json::to_value(failure).unwrap_or(serde_json::Value::Null), + ) +} + +/// 失败标签:能由 typed 事实决定的先看变体,只有产生层还没有 typed 出口的才回落到文本分类。 +/// +/// 修掉两处错标:`TimedOut` 撞的是哪条上限是 typed 的(改造前两种超时都被读成空闲超时,而 +/// `turn-hard-timeout` 那条标签没有任何生产者、根本不可达);通道断开也不再要求诊断文本里恰好 +/// 出现 "transport closed"。 +fn direct_codex_error_code(failure: &TurnError) -> &'static str { + match failure { + TurnError::TimedOut(payload) => match payload.deadline { + Deadline::ResponseIdle => "turn-idle-timeout", + Deadline::TurnHardLimit => "turn-hard-timeout", + }, + TurnError::TransportClosed(_) => "transport-closed", + // typed 分类优先于文本判据:通道断开的原文不一定带 "transport closed", + // 不能让分类结果取决于宿主换了哪句 Display。 + TurnError::ModelCallFailed(payload) => match payload.kind { + ModelCallKind::TransportBroken => "transport-closed", + // 其余 kind 还没有专属 code,仍按宿主原文兜底(TODO(文本判据))。 + _ => classify_direct_codex_error(&payload.detail), + }, + // 产生层还没有 typed 出口:按宿主原文分类(TODO(文本判据))。 + TurnError::ProjectRootUnanchored(_) + | TurnError::EnvironmentNotReady(_) + | TurnError::HostStateUnavailable(_) + | TurnError::TurnInterrupted(_) + | TurnError::ReviewRequired { .. } + | TurnError::RepairRequired { .. } => { + classify_direct_codex_error(&failure.diagnostic_detail()) + } + TurnError::SuperErrorFromStringPlusStage(payload) => { + classify_direct_codex_error(&payload.detail) + } + TurnError::Unclassified(payload) => classify_direct_codex_error(&payload.detail), + } +} + +#[allow(clippy::too_many_arguments)] +fn record_direct_codex_failure_facts( + root: &Path, + client_turn_id: Option<&str>, + stage: &str, + error_code: &str, + retryable: bool, + recovery_hint: &str, + detail: String, + summary: String, + typed_error: serde_json::Value, +) -> String { let summary = summary.split_whitespace().collect::>().join(" "); let summary = if summary.trim().is_empty() { "未提供可安全展示的详细原因".to_string() } else { summary }; - let retryable = failure.is_retryable(); - let recovery_hint = failure - .recovery_hint() - .unwrap_or("请重试;如持续失败请检查项目诊断"); let diagnostic = serde_json::json!({ "schemaVersion": "direct-codex-diagnostic.v1", - "stage": stage.id(), + "stage": stage, "summary": summary, "retryable": retryable, "recoveryHint": recovery_hint, @@ -2000,17 +2090,15 @@ pub(crate) fn record_direct_codex_failure( } else { "未能保存项目诊断" }; - // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 - let error_code = classify_direct_codex_error(&detail); // sidecar 照写,但引用不进用户可见文案。 let _ = persist_agent_runtime_error( root, client_turn_id, "direct-codex", - stage.id(), + stage, error_code, &detail, - serde_json::to_value(failure).unwrap_or(serde_json::Value::Null), + typed_error, None, serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, @@ -2019,7 +2107,7 @@ pub(crate) fn record_direct_codex_failure( .ok(); let public_text = format!( "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", - stage.id(), + stage, error_code, retryable, diagnostic["summary"] @@ -2034,23 +2122,19 @@ pub(crate) fn record_direct_codex_failure( public_text } -/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 -/// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后原样把 +/// **结构化变体**交给前端。命令返回的就是变体本身,没有第二层包装、也没有预拼文案。 /// /// 命令边界只有这一份判据,禁止在各处再写一套。 pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> DirectTurnEnqueueFailure { - if !failure.is_reportable() { - return DirectTurnEnqueueFailure::new(failure); - } - let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnEnqueueFailure { - error: failure, - message, + failure: EnqueueError, +) -> EnqueueError { + if failure.is_reportable() { + record_direct_codex_enqueue_failure(root, &failure, client_turn_id); } + failure } fn direct_taonier_art_generation_runtime_context( @@ -4182,11 +4266,11 @@ fn is_direct_codex_item_started_work_detail(value: &str) -> bool { /// item activity remain `running` because they describe work rather than the /// user-visible reply body. fn direct_codex_observation_status( - observation: &DirectCodexTurnObservation, + observation: &TurnObservation, stream_enabled: bool, ) -> &'static str { match observation { - DirectCodexTurnObservation::AccumulatedText(_) if stream_enabled => "streaming", + TurnObservation::AccumulatedText(_) if stream_enabled => "streaming", _ => "running", } } @@ -4274,19 +4358,21 @@ pub(crate) fn check_direct_turn_preconditions( root: &Path, prompt: &str, creation_type: Option<&str>, -) -> Result<(), DirectTurnError> { +) -> Result<(), EnqueueError> { if !root.is_absolute() || !root.is_dir() { - return Err(DirectTurnError::ProjectRootUnusable); + return Err(EnqueueError::ProjectRootUnusable); } - enforce_project_permission_policy(root, "conversation.read") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; - enforce_project_permission_policy(root, "conversation.write") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + enforce_project_permission_policy(root, "conversation.read").map_err(|policy_detail| { + EnqueueError::PermissionRejected(PermissionRejected { policy_detail }) + })?; + enforce_project_permission_policy(root, "conversation.write").map_err(|policy_detail| { + EnqueueError::PermissionRejected(PermissionRejected { policy_detail }) + })?; if prompt.trim().is_empty() { - return Err(DirectTurnError::ContentEmpty); + return Err(EnqueueError::ContentEmpty); } direct_creation_type_system_context(creation_type) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; + .map_err(|detail| EnqueueError::InputRejected(InputRejected { detail }))?; Ok(()) } /// Direct 回合在 `direct_execution::begin` 之后的分段 trace。 @@ -4314,7 +4400,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt crate::analytics::store::AnalyticsWriter, )>, release_identity_generation: u64, -) -> Result { +) -> Result { let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { @@ -4336,8 +4422,10 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt // 在入队前就查过,能到这条通道的只有放行之后的事(连接、配置、历史注入、`turn/start` // 被拒、模型与交付)。所以不再有"直通调用方"的分支。 Err(failure) => { - let stage = failure.turn_failure_stage(); - let error = record_direct_codex_failure( + // 审计照写(`.agent/runtime/errors` / 应用日志 / 错误池),但返回值不再当载荷: + // typed 失败**原样透传**,前端拿到的是真正的变体(模型调用 / 超时 / 通道断开…), + // 而不是被统一重包成 `SuperErrorFromStringPlusStage` 后的 catch-all。 + let _ = record_direct_codex_failure( root, &failure, turn_emitter.map(|emitter| emitter.turn_id()), @@ -4348,10 +4436,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt if let Some(emitter) = turn_emitter { emitter.emit("failed", Some("none")); } - Err(DirectTurnError::TurnFailed { - stage, - detail: error, - }) + Err(failure) } } } @@ -4367,28 +4452,22 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::store::AnalyticsWriter, )>, release_identity_generation: u64, -) -> Result { +) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, creation_type, turn_emitter.is_none(), ) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; // CLI 没有首页适配器;可信、尚未交付的脚手架沿用同一宿主准备入口。 if requires_contract && turn_emitter.is_none() { crate::environment_check::prepare_new_web_project_at(root, Some("game")) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; } let execution_config = load_game_creator_app_config() - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })? + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))? .validation; let analytics_run = capture.as_ref().map(|(context, _)| { crate::analytics::run::Metadata::new( @@ -4405,9 +4484,7 @@ async fn run_direct_game_creator_turn_inner( analytics_run, ) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let execution_session = execution_guard.session(); execution_session.set_analytics_capture(capture.clone()); direct_turn_trace("session-ready"); @@ -4423,7 +4500,7 @@ async fn run_direct_game_creator_turn_inner( } direct_turn_trace("run-analytics-accepted"); // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 - let result: Result = async { + let result: Result = async { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } @@ -4441,9 +4518,7 @@ async fn run_direct_game_creator_turn_inner( .as_deref(), ) }) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?, }; if execution_session.newly_accepted { if let Ok(ledger) = execution_session.snapshot() { @@ -4462,16 +4537,11 @@ async fn run_direct_game_creator_turn_inner( } let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let base_system_prompt = - build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - }, - )?; + build_direct_codex_system_prompt_with_creation_type(root, creation_type) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; direct_turn_trace("system-prompt-ready"); // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, // 避免被长度上限截断,也不阻断任何工具。 @@ -4487,10 +4557,10 @@ async fn run_direct_game_creator_turn_inner( direct_turn_trace("executor-dispatch"); let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); - let mut observer = |observation: DirectCodexTurnObservation| { + let mut observer = |observation: TurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { - DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + TurnObservation::AccumulatedText(accumulated_text) => { let visible_text = project_direct_codex_accumulated_text( stream_enabled, &accumulated_text, @@ -4500,13 +4570,13 @@ async fn run_direct_game_creator_turn_inner( } emitter.emit(status, None); } - DirectCodexTurnObservation::AgentMessageSegment(accumulated_text) => { + TurnObservation::AgentMessageSegment(accumulated_text) => { if project_direct_codex_visible_text(&accumulated_text).is_none() { return; } emitter.emit(status, None); } - DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + TurnObservation::IntermediateText(intermediate_text) => { let is_visible = stream_enabled || is_direct_codex_item_started_work_detail(&intermediate_text); if is_visible @@ -4515,17 +4585,17 @@ async fn run_direct_game_creator_turn_inner( emitter.emit(status, None); } } - DirectCodexTurnObservation::Activity(activity) => { + TurnObservation::Activity(activity) => { emitter.emit(status, Some(activity)); } - DirectCodexTurnObservation::Reasoning(_) => { + TurnObservation::Reasoning(_) => { // 思考增量只把活动回合标成 running;正文由运行态事件投影下发。 emitter.emit("running", None); } } }; let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; + let mut turn_kind = TurnKind::User; let mut attempt = 1; let reply_result = loop { let result = direct_game_creator_codex_chat_at_with_optional_observer( @@ -4538,7 +4608,7 @@ async fn run_direct_game_creator_turn_inner( Some(direct_user_item.clone()), ) .await; - turn_kind = DirectCodexTurnKind::HostFeedback; + turn_kind = TurnKind::HostFeedback; match result { Ok(value) => { match super::direct_delivery::review_reply(root, &execution_session).await { @@ -4549,8 +4619,8 @@ async fn run_direct_game_creator_turn_inner( // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, + TurnError::ReviewRequired { detail } + | TurnError::RepairRequired { detail }, ) => { emitter.emit("running", Some("host-review")); feedback_prompt = format!( @@ -4561,6 +4631,15 @@ async fn run_direct_game_creator_turn_inner( Err(error) => break Err(error), } } + // 封口复核要求继续当前返修批次(app-server 封口复核):**控制流,不是失败**。 + // 这是 `direct_delivery::review_reply` 之外的第二条返修来源,处理与上面的 + // 返修要求完全一样——把要求写回 prompt 再跑一轮;次数上限在产生侧(执行账本 + // 的批次上限),这里不另设计数。不接住它,回合会漏到终态收口被静默吞掉。 + Err(TurnError::RepairRequired { detail }) => { + emitter.emit("running", Some("host-review")); + feedback_prompt = + format!(prompt_text!("direct.deliveryFeedback"), detail = detail); + } // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, // 第二次拿到 `None` 时还会把空串当成回复返回。 Err(error) => match super::direct_delivery::terminal_report(&execution_session) @@ -4569,7 +4648,8 @@ async fn run_direct_game_creator_turn_inner( None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS && error.is_model_repairable() => { - let detail = truncate_agent_runtime_text(&error.to_string(), 1800); + let detail = + truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); emitter.emit("running", Some("error-feedback")); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail); @@ -4582,7 +4662,7 @@ async fn run_direct_game_creator_turn_inner( reply_result } else { let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; + let mut turn_kind = TurnKind::User; let mut attempt = 1; let response = loop { let result = direct_game_creator_codex_chat_at_with_optional_observer( @@ -4595,7 +4675,7 @@ async fn run_direct_game_creator_turn_inner( Some(direct_user_item.clone()), ) .await; - turn_kind = DirectCodexTurnKind::HostFeedback; + turn_kind = TurnKind::HostFeedback; match result { Ok(value) => { match super::direct_delivery::review_reply(root, &execution_session).await { @@ -4605,8 +4685,8 @@ async fn run_direct_game_creator_turn_inner( // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, + TurnError::ReviewRequired { detail } + | TurnError::RepairRequired { detail }, ) => { feedback_prompt = format!( prompt_text!("direct.deliveryFeedback"), @@ -4617,6 +4697,11 @@ async fn run_direct_game_creator_turn_inner( Err(error) => return Err(error), } } + // 同流式分支:封口复核的返修要求是控制流,写回 prompt 再跑一轮。 + Err(TurnError::RepairRequired { detail }) => { + feedback_prompt = + format!(prompt_text!("direct.deliveryFeedback"), detail = detail); + } // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` // 时还会绕过下面那条"未返回结果"的兜底错误)。 Err(error) => match super::direct_delivery::terminal_report(&execution_session) @@ -4625,7 +4710,8 @@ async fn run_direct_game_creator_turn_inner( None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS && error.is_model_repairable() => { - let detail = truncate_agent_runtime_text(&error.to_string(), 1800); + let detail = + truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail); } @@ -4634,15 +4720,12 @@ async fn run_direct_game_creator_turn_inner( } }; response.ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "陶泥儿错误反馈回合未返回结果", - ) + TurnError::turn_failed(FailureStage::CodeGeneration, "陶泥儿错误反馈回合未返回结果") }) }?; let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + TurnError::turn_failed( + FailureStage::CodeGeneration, "陶泥儿未返回可展示的回复".to_string(), ) })?; @@ -4660,10 +4743,7 @@ async fn run_direct_game_creator_turn_inner( } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error, - ) + TurnError::turn_failed(FailureStage::VersionRegistration, error) })?; } Ok(visible_reply) @@ -4787,7 +4867,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, prompt: &str, prepare_art: bool, -) -> Result { +) -> Result { if prepare_art { let mode = if direct_prompt_requests_fresh_art_generation(prompt) { DirectTaonierArtPreparationMode::Regenerate @@ -4796,14 +4876,11 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( }; ensure_direct_taonier_art_package_at(root, prompt, mode) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::ArtPreparation, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?; } let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let mut system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + let mut system_prompt = build_direct_codex_system_prompt(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; if prepare_art { system_prompt.push_str(prompt_text!("direct.production.preparedArt")); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); @@ -4812,11 +4889,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( emit_direct_game_creator_progress(root, "codex.start", "正在修改已有游戏并进行试玩"); } let _initial_reply = - direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?; let initial_output_fingerprint = direct_codex_output_fingerprint(root); let mut completion_error = direct_game_output_completion_error(root); let mut evidence_attempts = 0_usize; @@ -4832,9 +4905,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( evidence = Some( run_direct_browser_evidence_at(root, evidence_attempts) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?, ); browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); } else { @@ -4848,11 +4919,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( completion_error.as_deref(), ); emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); - let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + let mut reply = + direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?; let repaired_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); if completion_error.is_none() @@ -4864,9 +4932,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( evidence = Some( run_direct_browser_evidence_at(root, evidence_attempts) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?, ); browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); } else if completion_error.is_some() { @@ -4885,11 +4951,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "codex.repair", "试玩未通过,正在进行最后一次自主修复", ); - reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?; let final_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); if completion_error.is_none() @@ -4905,10 +4967,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, - error, - ) + TurnError::turn_failed(FailureStage::BrowserValidation, error) })?, ); } else if completion_error.is_some() { @@ -4919,30 +4978,25 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if let Some(evidence) = evidence.as_ref() { write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error, - ) + TurnError::turn_failed(FailureStage::VersionRegistration, error) })?; } - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, + return Err(TurnError::turn_failed( + FailureStage::VersionRegistration, format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), )); } let Some(evidence) = evidence else { - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", )); }; if !evidence.passed { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?; - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?; + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, format!( "Codex 自主试玩未通过,未登记完成版本:{}", evidence @@ -4957,11 +5011,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( } if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?; - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?; + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" .to_string(), )); @@ -4971,12 +5023,10 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "codex.ready", "游戏已通过真实浏览器试玩,正在登记项目版本", ); - sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( - |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), - )?; - write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( - |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), - )?; + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; + write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); Ok(format!( "{}\n\n{}", @@ -5037,47 +5087,45 @@ mod tests { #[test] fn model_failures_are_fed_back_only_when_another_attempt_can_repair_them() { // 工具 / 构建 / 试玩这类"代码里的问题"没有 typed 事实,按可修处理。 - assert!(DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!(TurnError::turn_failed( + FailureStage::CodeGeneration, "agc_browser_playtest 失败:页面抛出异常" ) .is_model_repairable()); - assert!(DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "npm run build 编译失败" - ) - .is_model_repairable()); + assert!( + TurnError::turn_failed(FailureStage::CodeGeneration, "npm run build 编译失败") + .is_model_repairable() + ); // 模型自报 `codexErrorInfo=other`:分类认得出,仍值得再跑一轮。 - assert!(DirectTurnError::from_model_call(&LlmError::InvalidRequest( + assert!(TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:other detail=fields=codexErrorInfo".into() )) .is_model_repairable()); // 鉴权、通道断开、上下文超限:再跑一轮不会变好。 - assert!(!DirectTurnError::from_model_call(&LlmError::InvalidRequest( + assert!(!TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:unauthorized".into() )) .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + assert!(!TurnError::from_model_call(&LlmError::Transport( "Codex app-server 连接已关闭".into() )) .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Upstream { + assert!(!TurnError::from_model_call(&LlmError::Upstream { status_code: 503, message: "Codex app-server 上游服务暂时不可用".into(), }) .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "项目身份不匹配" - ) - .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!( + !TurnError::turn_failed(FailureStage::CodeGeneration, "项目身份不匹配") + .is_model_repairable() + ); + assert!(!TurnError::turn_failed( + FailureStage::CodeGeneration, "工具参数 attempt 必须是 1 到 3 的整数" ) .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!(!TurnError::turn_failed( + FailureStage::CodeGeneration, "authentication-required: HTTP 401" ) .is_model_repairable()); @@ -5112,7 +5160,7 @@ mod tests { #[test] fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() { - let error = DirectTurnError::from_model_call(&LlmError::Upstream { + let error = TurnError::from_model_call(&LlmError::Upstream { status_code: 409, message: "泥点余额不足".into(), }); @@ -5124,8 +5172,8 @@ mod tests { assert!(!error.is_retryable()); // 深层(还没 typed 出口)的同一个事实也必须落到同一条建议上。 - let deep = DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + let deep = TurnError::turn_failed( + FailureStage::ArtPreparation, "平台图片生成任务失败:泥点余额不足", ); assert_eq!( @@ -5142,12 +5190,12 @@ mod tests { /// 界面还会出现"请重新登录陶泥儿后重试"却同时标着不可重试的矛盾组合。 #[test] fn direct_codex_authentication_failure_is_retryable_in_both_paths() { - let native = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let native = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:unauthorized".into(), )); assert!(native.is_retryable()); - let deep = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let deep = TurnError::turn_failed( + FailureStage::CodeGeneration, "authentication-required: HTTP 401", ); assert!(deep.is_retryable()); @@ -5158,8 +5206,8 @@ mod tests { #[test] fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { - let error = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let error = TurnError::turn_failed( + FailureStage::CodeGeneration, "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl", ); assert_eq!( @@ -5169,8 +5217,8 @@ mod tests { assert!(!error.is_retryable()); // 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。 - let io_error = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let io_error = TurnError::turn_failed( + FailureStage::CodeGeneration, "打开 DirectProject 历史失败:拒绝访问", ); assert_eq!( @@ -5188,10 +5236,7 @@ mod tests { "获取DirectProject 历史追加写{}", crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER ); - let append_lock = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - append_lock_timeout, - ); + let append_lock = TurnError::turn_failed(FailureStage::CodeGeneration, append_lock_timeout); assert_eq!( append_lock.recovery_hint(), Some("另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求") @@ -5201,10 +5246,8 @@ mod tests { "{}C:/project", crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX ); - let write_lock = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - write_lock_contention, - ); + let write_lock = + TurnError::turn_failed(FailureStage::CodeGeneration, write_lock_contention); assert_eq!( write_lock.recovery_hint(), Some("当前项目仍有写入正在结束,请稍后再次发送该需求"), @@ -5306,7 +5349,7 @@ mod tests { assert!( matches!( terminal, - ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { status, .. } if *status == TurnCompletedStatus::Aborted ), "{terminal:?}" ); @@ -5853,9 +5896,9 @@ mod tests { #[test] fn direct_observation_status_separates_reply_stream_from_work_activity() { - let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string()); - let intermediate = DirectCodexTurnObservation::IntermediateText("正在调用工具".to_string()); - let activity = DirectCodexTurnObservation::Activity("command-exec"); + let accumulated = TurnObservation::AccumulatedText("阶段性回复".to_string()); + let intermediate = TurnObservation::IntermediateText("正在调用工具".to_string()); + let activity = TurnObservation::Activity("command-exec"); assert_eq!( direct_codex_observation_status(&accumulated, true), @@ -7279,24 +7322,39 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_enqueue_failure( - &root, - Some("direct-codex:turn-1:user"), - DirectTurnError::EnvironmentNotReady { - detail: "Codex app-server 启动失败:找不到可执行文件".into(), - }, - ) - .message; + let failure = EnqueueError::EnvironmentNotReady(EnvironmentNotReady { + detail: "Codex app-server 启动失败:找不到可执行文件".into(), + }); + // 命令边界只补诊断,**原样**把结构化变体交给前端,不拼第二份文案。 + assert_eq!( + direct_turn_enqueue_failure(&root, Some("direct-codex:turn-1:user"), failure.clone()), + failure + ); - assert!(text.contains("direct-codex-failure:v2"), "{text}"); - assert!(!text.contains("详情:"), "{text}"); let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) .expect("runtime error directory") .filter_map(Result::ok) .collect::>(); assert_eq!(entries.len(), 1); - let sidecar = std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar"); - assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}"); + let sidecar: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar"), + ) + .expect("runtime error sidecar 是结构化 JSON"); + // 收口文案(`direct-codex-failure:v2 ...`)只进错误上报池与应用日志,不进结构化 sidecar。 + // schema 版本跟随后端常量:master 已升到 v3,别在这里钉死旧字面量。 + assert_eq!(sidecar["schemaVersion"], AGENT_RUNTIME_ERROR_SCHEMA_VERSION); + assert_eq!(sidecar["source"], "direct-codex"); + assert_eq!(sidecar["stage"], "code-generation"); + assert_eq!(sidecar["code"], "app-server-launch-failed"); + assert_eq!(sidecar["clientTurnId"], "direct-codex:turn-1:user"); + assert!(!sidecar.to_string().contains("详情:"), "{sidecar}"); + // v3 起自由文本字段从 `detail` 改叫 `message`;typed 载荷单独进 `error`。 + assert!( + sidecar["message"] + .as_str() + .is_some_and(|detail| detail.contains("Codex app-server 启动失败")), + "{sidecar}" + ); } /// 用户的正常操作结果不留痕:空内容只给一句话,不写诊断。 @@ -7306,36 +7364,47 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; - - assert_eq!(text, "聊天内容不能为空"); + let failure = EnqueueError::ContentEmpty; + assert_eq!( + direct_turn_enqueue_failure(&root, None, failure.clone()), + failure + ); assert!(!root.join(".agent/runtime/errors").exists()); } - /// 回合失败已经在上游写过诊断,边界不得再写第二份。 + /// 入队边界只认入队失败这张表:用户自己就能修的业务拒绝一条都不留痕。 + /// 回合失败进不了这里——两张表分型,回合失败在上游已经写过诊断。 #[test] - fn turn_failure_text_is_not_recorded_twice_at_the_boundary() { + fn business_enqueue_rejections_are_not_recorded() { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let failure = - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); - let recorded = record_direct_codex_failure(&root, &failure, None); - // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_enqueue_failure( - &root, - None, - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ) - .message; - - assert_eq!(text, recorded); - let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) - .expect("runtime error directory") - .filter_map(Result::ok) - .collect::>(); - assert_eq!(entries.len(), 1, "边界不得为同一条失败再写一份诊断"); + for failure in [ + EnqueueError::ClientTurnIdMissing, + EnqueueError::ClientTurnIdMalformed(ClientTurnIdMalformed { + min_chars: 8, + max_chars: 64, + }), + EnqueueError::QueueFull(QueueFull { limit: 3 }), + EnqueueError::ProjectRootUnusable, + EnqueueError::PermissionRejected(PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + }), + EnqueueError::InputRejected(InputRejected { + detail: "用户条目不合法".into(), + }), + EnqueueError::ContentEmpty, + ] { + assert_eq!( + direct_turn_enqueue_failure(&root, None, failure.clone()), + failure + ); + } + assert!( + !root.join(".agent/runtime/errors").exists(), + "业务拒绝不得留痕" + ); } #[test] @@ -7345,8 +7414,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), None, @@ -7377,6 +7446,22 @@ mod tests { assert!(!diagnostic.contains("provider.example")); } + /// typed 分类优先于文本判据:`ModelCallKind::TransportBroken` 的原文里没有 + /// `transport closed` / `连接已关闭`,也要标成 `transport-closed`。 + #[test] + fn model_call_transport_broken_is_classified_from_the_typed_kind() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + let error = TurnError::from_model_call(&LlmError::Transport( + "宿主自己写的收场说明,不含分类标记".to_string(), + )); + + let text = record_direct_codex_failure(&root, &error, None); + + assert!(text.contains("code=transport-closed"), "{text}"); + } + #[test] fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() { let parent = tempfile::tempdir().expect("temp dir"); @@ -7388,8 +7473,8 @@ mod tests { .to_string(); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + &TurnError::turn_failed( + FailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), None, @@ -7428,8 +7513,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), None, @@ -7449,8 +7534,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index d907d60b6..2fbc2d2f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -7,9 +7,9 @@ use super::*; pub(crate) fn normalize_direct_client_turn_id( client_turn_id: Option<&str>, -) -> Result { +) -> Result { let Some(client_turn_id) = client_turn_id else { - return Err(DirectTurnError::ClientTurnIdMissing); + return Err(EnqueueError::ClientTurnIdMissing); }; let client_turn_id = client_turn_id.trim(); let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) @@ -20,10 +20,10 @@ pub(crate) fn normalize_direct_client_turn_id( .is_some_and(|byte| byte.is_ascii_alphanumeric()); let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if !valid_length || !valid_first || !valid_rest { - return Err(DirectTurnError::ClientTurnIdMalformed { + return Err(EnqueueError::ClientTurnIdMalformed(ClientTurnIdMalformed { min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS, max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS, - }); + })); } Ok(client_turn_id.to_string()) } @@ -37,7 +37,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 /// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `thread_manager::dispatch`), /// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 -/// 错误诊断,返回串不带诊断引用。 +/// 错误诊断;命令返回的就是 typed 变体本身。 /// /// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 /// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 @@ -47,10 +47,10 @@ pub(crate) fn normalize_direct_client_turn_id( #[tauri::command] pub(crate) async fn enqueue_direct_codex_turn( project_path: String, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, client_turn_id: Option, -) -> Result<(), DirectTurnEnqueueFailure> { +) -> Result<(), EnqueueError> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id) @@ -67,23 +67,23 @@ pub(crate) async fn enqueue_direct_codex_turn( /// 回合事件与整轮都在放行那一侧。 async fn enqueue_direct_codex_turn_typed( root: &Path, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, client_turn_id: Option, -) -> Result<(), DirectTurnError> { +) -> Result<(), EnqueueError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - DirectTurnError::HostStateUnavailable { + EnqueueError::HostStateUnavailable(HostStateUnavailable { detail: redact_agent_runtime_error( root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500, ), - } + }) })?; // 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。 let user_item = freeze_direct_codex_user_item(root, &user_item) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; + .map_err(|detail| EnqueueError::InputRejected(InputRejected { detail }))?; let user_prompt = direct_codex_user_item_to_prompt(&user_item); check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; let thread_id = thread_id_for_project(root); @@ -91,25 +91,27 @@ async fn enqueue_direct_codex_turn_typed( // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 // 临界区里的容量检查(下面 `enqueue_pending_turn`):这里只是快速失败,中间被别人的消息 // 挤满时那一条照样拦得住。 - queue_has_room(pending_turn_count(&thread_id)).map_err(|_| DirectTurnError::QueueFull { - limit: MAX_PENDING_TURNS, + queue_has_room(pending_turn_count(&thread_id)).map_err(|_| { + EnqueueError::QueueFull(QueueFull { + limit: MAX_PENDING_TURNS, + }) })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await .map_err(|error| { let detail = redact_agent_runtime_error(root, &error, 1800); - DirectTurnError::EnvironmentNotReady { detail } + EnqueueError::EnvironmentNotReady(EnvironmentNotReady { detail }) })?; // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 - let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms()); + let pending = PendingTurn::new(turn_id, user_item, creation_type, now_ms()); match enqueue_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { - return Err(DirectTurnError::QueueFull { + return Err(EnqueueError::QueueFull(QueueFull { limit: MAX_PENDING_TURNS, - }) + })) } } // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 @@ -146,7 +148,23 @@ mod tests { } } - fn user_item(text: &str) -> DirectCodexUserItem { + /// 载荷里那份宿主原文:测试只关心"有没有原因 / 是哪一类",不关心前端怎么拼文案。 + fn failure_detail(failure: &TurnFailure) -> String { + match failure { + TurnFailure::ProjectRootUnanchored(payload) => payload.cause.clone(), + TurnFailure::EnvironmentNotReady(payload) => payload.detail.clone(), + TurnFailure::HostStateUnavailable(payload) => payload.detail.clone(), + TurnFailure::ModelCallFailed(payload) => payload.detail.clone(), + TurnFailure::TransportClosed(payload) => payload.diagnostic.clone(), + TurnFailure::TimedOut(payload) => format!("{:?}", payload.deadline), + TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), + TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), + TurnFailure::Unclassified(payload) => payload.detail.clone(), + TurnFailure::HostDropped => String::new(), + } + } + + fn user_item(text: &str) -> UserItem { serde_json::from_value(serde_json::json!({ "type": "message", "role": "user", @@ -188,10 +206,10 @@ mod tests { .collect::>(); assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}"); let (status, failure, user_item_id) = terminal[0]; - assert_eq!(status, "failed"); + assert_eq!(*status, TurnCompletedStatus::Failed); let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( - !failure.message.trim().is_empty(), + !failure_detail(failure).trim().is_empty(), "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); @@ -315,20 +333,13 @@ mod tests { .collect::>(); assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}"); let (status, failure) = terminals[0]; - assert_eq!(status, "failed"); + assert_eq!(*status, TurnCompletedStatus::Failed); let failure = failure.as_ref().expect("失败终态必须带载荷"); - assert!( - failure.message.contains("写入本项目对话历史失败"), - "{}", - failure.message - ); + let detail = failure_detail(failure); + assert!(detail.contains("写入本项目对话历史失败"), "{detail}"); // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 // 不许出现在这里(它只属于可留痕的入队失败)。 - assert!( - !failure.message.contains("direct-codex-failure"), - "{}", - failure.message - ); + assert!(!detail.contains("direct-codex-failure"), "{detail}"); // 占用已释放:下一轮还能继续。 assert!(!crate::agent::thread_turn_is_active(&thread_id)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 31cd98b59..a3a32f57f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -4236,8 +4236,9 @@ pub(crate) async fn start_direct_tool_bridge( pub(crate) async fn start_external_client_tool_bridge( root: &Path, controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result { - start_tool_bridge_for_source(root, controlled_web_search, false).await + start_tool_bridge_for_source(root, controlled_web_search, direct_turn_execution).await } async fn start_tool_bridge_for_source( @@ -4421,6 +4422,115 @@ pub(super) fn drain_analytics_test_writer( #[cfg(test)] mod tests { + #[test] + fn browser_gameplay_results_reach_model_with_matching_viewports_and_screenshots() { + for passed in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let evidence = if passed { + super::super::direct_validation::tests::browser_evidence_fixture(temp.path()) + } else { + super::super::direct_validation::tests::failed_browser_evidence_fixture(temp.path()) + }; + for viewport in &evidence.viewport_results { + image::RgbaImage::new(2, 2) + .save(&viewport.screenshot_path) + .unwrap(); + } + let receipt = super::super::direct_validation::browser_tool_result( + temp.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + assert!(evidence.viewport_results.iter().all(|result| result.passed)); + assert_eq!(receipt["passed"], passed); + let outcome = bridge_validation_result( + temp.path(), + Ok(receipt.clone()), + |report, screenshots| BrowserPlaytestError::PlaytestNotPassed { + report, + screenshots, + }, + ) + .map_err(|failure| { + let error = ToolCallError::from(&failure); + assert_eq!(error.error["PlaytestNotPassed"]["report"], receipt); + assert!(error.error["PlaytestNotPassed"] + .get("screenshots") + .is_none()); + error + }); + let state = direct_tool_bridge_state(temp.path().to_path_buf()); + let response = + compose_direct_tool_outcome(&state, "agc_browser_playtest", "{}", false, outcome); + assert_eq!(response["isError"], !passed); + let text = response["content"][0]["text"].as_str().unwrap(); + let model_receipt: Value = + serde_json::from_str(&text[text.find('{').unwrap()..]).unwrap(); + assert_eq!(model_receipt, receipt); + assert_eq!( + response["content"] + .as_array() + .unwrap() + .iter() + .filter(|block| block["type"] == "image") + .count(), + 2 + ); + let summary = model_receipt["summary"].as_str().unwrap(); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let report = &model_receipt["gameplayResults"][index]; + assert_eq!( + report["viewport"], + serde_json::to_value(entry.viewport).unwrap() + ); + assert_eq!(report["passed"], entry.result.passed); + assert_eq!( + report["assertions"], + serde_json::to_value(&entry.result.assertions).unwrap() + ); + assert_eq!(report["finalPhase"], "ready"); + assert_eq!( + report["finalSequence"], + entry.result.final_sequence.unwrap() + ); + let viewport = report["viewport"].as_str().unwrap(); + let line = summary + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry.result.passed + ))); + if let Some(reason) = entry.result.diagnostics.first() { + assert_eq!(report["diagnostics"][0], reason.as_str()); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry + .result + .assertions + .iter() + .find(|assertion| !assertion.passed) + { + assert!(line.contains(&assertion.name)); + } + } + } + } + + #[tokio::test] + async fn external_client_bridge_can_be_enabled_for_a_direct_turn() { + let temporary = tempfile::tempdir().unwrap(); + init_local_game_project_at(temporary.path(), "external-direct-turn", "外部直连回合") + .unwrap(); + let bridge = start_external_client_tool_bridge(temporary.path(), false, true) + .await + .expect("direct Claude bridge"); + assert!(bridge.state.direct_turn_execution); + drop(bridge); + } + #[tokio::test] async fn host_cancellation_releases_queued_image_without_a_new_post() { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 6e2d51cb4..28aded7d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -35,12 +35,16 @@ const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); -static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock< + Mutex>, +> = OnceLock::new(); tokio::task_local! { static EXTERNAL_MCP_BRIDGE_URL: String; } pub(crate) struct ExternalMcpServer { + token: String, + _direct_turn_guard: Option, _bridge: super::direct_tool_bridge::DirectToolBridge, task: tokio::task::JoinHandle<()>, } @@ -1759,6 +1763,21 @@ async fn handle_external_mcp_http_request( pub(crate) async fn start_external_mcp_loopback( root: &Path, controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, false).await +} + +pub(crate) async fn start_external_mcp_loopback_for_direct_turn( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + start_external_mcp_loopback_with_mode(root, controlled_web_search, true).await +} + +async fn start_external_mcp_loopback_with_mode( + root: &Path, + controlled_web_search: bool, + direct_turn_execution: bool, ) -> Result<(String, String), String> { let root = validate_direct_tools_project_root(root)?; let session = current_platform_session() @@ -1771,12 +1790,20 @@ pub(crate) async fn start_external_mcp_loopback( let address = listener .local_addr() .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; - let bridge = - super::direct_tool_bridge::start_external_client_tool_bridge(&root, controlled_web_search) - .await?; + let bridge = super::direct_tool_bridge::start_external_client_tool_bridge( + &root, + controlled_web_search, + direct_turn_execution, + ) + .await?; + let direct_turn_guard = if direct_turn_execution { + Some(bridge.begin_user_turn()?) + } else { + None + }; let state = ExternalMcpHttpState { bridge_url: bridge.url().to_string(), - root, + root: root.clone(), token: token.clone(), session_user_id: session.user_id, session_identity_generation: session.identity_generation, @@ -1790,24 +1817,40 @@ pub(crate) async fn start_external_mcp_loopback( let _ = axum::serve(listener, app).await; }); let url = format!("http://127.0.0.1:{}{route}", address.port()); - let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let registry = + EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(std::collections::BTreeMap::new())); let mut guard = registry .lock() .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; - if let Some(previous) = guard.take() { - drop(previous); - } - *guard = Some(ExternalMcpServer { - _bridge: bridge, - task, - }); + guard.insert( + root, + ExternalMcpServer { + token: token.clone(), + _direct_turn_guard: direct_turn_guard, + _bridge: bridge, + task, + }, + ); Ok((url, token)) } pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { if let Ok(mut guard) = registry.lock() { - guard.take(); + guard.clear(); + } + } +} + +pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { + let Ok(root) = root.canonicalize() else { + return; + }; + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } } } @@ -2180,10 +2223,13 @@ mod tests { .unwrap(); std::fs::create_dir(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_GODOT_EDITOR_PLUGIN_ID, @@ -2281,10 +2327,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for (cocos_enabled, unity_enabled) in [(false, false), (true, false), (false, true), (true, true)] { @@ -2419,10 +2468,13 @@ mod tests { let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-"); std::fs::create_dir_all(project.path().join(".agent")).unwrap(); std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap(); - let bridge = - super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false) - .await - .unwrap(); + let bridge = super::super::direct_tool_bridge::start_external_client_tool_bridge( + project.path(), + false, + false, + ) + .await + .unwrap(); for enabled in [false, true, false, true] { crate::builtin_plugins::set_enabled( crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID, @@ -3464,14 +3516,16 @@ mod tests { "title": "工具链远端画布" }]}}), ); - } else if route.starts_with("/api/editor/assets/library") { + } else if request_line.starts_with(&format!( + "GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} " + )) { tool_chain_write_json( &mut stream, "200 OK", - json!({"data": {"library": {"folders": [{ + json!({"data": {"folder": { "folderId": TOOL_CHAIN_ASSET_FOLDER_ID, "label": "工具链远端目录" - }]}}}), + }}}), ); } else if request_line.starts_with(expected_submission) { tool_chain_write_json( @@ -4017,6 +4071,9 @@ mod tests { ); assert_eq!(requests.len(), 6, "{requests:?}"); + assert!(requests.iter().any(|request| request.starts_with(&format!( + "GET /api/editor/assets/folders/{TOOL_CHAIN_ASSET_FOLDER_ID} " + )))); let submission = requests .iter() .find(|request| request.starts_with("POST /api/editor/images/edits ")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs deleted file mode 100644 index 196ef735a..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ /dev/null @@ -1,1200 +0,0 @@ -//! Direct 回合链路的 typed error:从命令入口到出口只传这一种错误。 -//! -//! 为什么不是 `struct { kind, message }`:两层错误(**调用级拒绝**与**回合级失败**)根本不共享 -//! 字段——并发拒绝要带两个 invocation id、模型自报失败要带原生分类、等待超时要带是哪条上限、 -//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 -//! 伪结构化,也不靠对错误文本做子串匹配。 -//! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): -//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 -//! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 -//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 -//! -//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 -//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 -//! -//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: -//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 -//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的 -//! [`DirectTurnEnqueueFailure`](变体 + 一条可展示 `message`);`message` 有两条来源: -//! 不需要留痕的失败用 [`DirectTurnError`] 的 `Display`,需要留痕的宿主 / 环境事实改用 -//! `record_direct_codex_failure` 生成的脱敏诊断收口文案(`direct-codex-failure:v2 …`,同时写 -//! `.agent/runtime/errors` 与错误上报池)。其余只需要一句文案的出口仍由 `Display` 生成。 -//! 无论哪条来源,Rust 侧任何地方都不解析这个字符串。 -//! -//! 谁负责产生哪个变体: -//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; -//! - app-server 投影([`DirectTurnError::from_model_call`]):模型 / 上游 / 通道类失败; -//! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 - -use std::fmt; - -use platform_llm::LlmError; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 -/// -/// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 -/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 -const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; - -/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 -/// -/// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexFailureStage { - ArtPreparation, - CodeGeneration, - BrowserValidation, - VersionRegistration, -} - -impl DirectCodexFailureStage { - pub(crate) fn id(self) -> &'static str { - match self { - Self::ArtPreparation => "art-preparation", - Self::CodeGeneration => "code-generation", - Self::BrowserValidation => "browser-validation", - Self::VersionRegistration => "version-registration", - } - } -} - -/// 宿主等不到模型回执时,撞的是哪一条上限。 -/// -/// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnDeadline { - /// 空闲上限:一段时间没有新事件。 - ResponseIdle, - /// 回合硬上限:整轮的总时间。 - TurnHardLimit, -} - -impl DirectTurnDeadline { - /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 - fn message(self) -> &'static str { - match self { - Self::ResponseIdle => "等待模型执行回执超时,不能自动重放未确认操作。", - Self::TurnHardLimit => "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", - } - } -} - -/// Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 -/// -/// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), -/// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 -/// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 -/// -/// 线上取值只给界面选语气用,前端不得拿它做流程分支。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde( - tag = "type", - rename_all = "kebab-case", - rename_all_fields = "camelCase" -)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexNativeKind { - ContextWindowExceeded, - SessionBudgetExceeded, - UsageLimitExceeded, - RequestTooLarge, - StreamRequired, - CyberPolicy, - SandboxError, - ThreadRollbackFailed, - BadRequest, - Unauthorized, - ActiveTurnNotSteerable, - /// 原生分类为 `other`,或本版本还不认识的新取值。 - Other { - kind: String, - }, -} - -impl DirectCodexNativeKind { - fn from_id(kind: &str) -> Self { - match kind { - "context-window-exceeded" => Self::ContextWindowExceeded, - "session-budget-exceeded" => Self::SessionBudgetExceeded, - "usage-limit-exceeded" => Self::UsageLimitExceeded, - "request-too-large" => Self::RequestTooLarge, - "stream-required" => Self::StreamRequired, - "cyber-policy" => Self::CyberPolicy, - "sandbox-error" => Self::SandboxError, - "thread-rollback-failed" => Self::ThreadRollbackFailed, - "bad-request" => Self::BadRequest, - "unauthorized" => Self::Unauthorized, - "active-turn-not-steerable" => Self::ActiveTurnNotSteerable, - kind => Self::Other { - kind: kind.to_string(), - }, - } - } - - /// 这一条分类是不是"再让模型跑一次也不会变好"。 - /// - /// 与旧行为逐条对齐:旧口径是"原因文本里出现这些分类名就不再反馈给模型",其余(含 `other` - /// 与未知分类)继续反馈。分类现在是 typed 的,判据不再依赖文本里出现过什么。 - fn is_terminal(&self) -> bool { - match self { - Self::ContextWindowExceeded - | Self::SessionBudgetExceeded - | Self::UsageLimitExceeded - | Self::RequestTooLarge - | Self::StreamRequired - | Self::CyberPolicy - | Self::SandboxError - | Self::ThreadRollbackFailed - | Self::BadRequest - | Self::Unauthorized => true, - Self::ActiveTurnNotSteerable | Self::Other { .. } => false, - } - } - - /// 给用户看的稳定摘要:只有"哪一类原因值得单独说一句"的分类才有。 - fn public_summary(&self) -> Option<&'static str> { - match self { - Self::ContextWindowExceeded => Some("模型上下文已超限"), - Self::SessionBudgetExceeded => Some("本次会话预算已耗尽"), - Self::UsageLimitExceeded => Some("用量已达上限"), - Self::RequestTooLarge => Some("模型请求体过大"), - Self::CyberPolicy => Some("安全策略拒绝了本次请求"), - Self::SandboxError => Some("工作区隔离启动失败"), - _ => None, - } - } - - /// 恢复建议:分类决定动作;没把握的分类不给建议,交给阶段兜底。 - fn recovery_hint(&self) -> Option<&'static str> { - match self { - Self::ContextWindowExceeded | Self::RequestTooLarge => { - Some("请缩小本次需求范围或减少参考图后重试") - } - Self::SessionBudgetExceeded | Self::UsageLimitExceeded => { - Some("请在账户页面确认可用额度后重试,或新建项目继续") - } - Self::Unauthorized => Some("登录态可能已失效,请重新登录陶泥儿后重试"), - Self::CyberPolicy => Some("请调整本次需求的内容后重试"), - Self::SandboxError => Some("请检查项目目录权限后重试"), - Self::StreamRequired - | Self::ThreadRollbackFailed - | Self::BadRequest - | Self::ActiveTurnNotSteerable - | Self::Other { .. } => None, - } - } - - /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— - /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 - /// - /// `Unauthorized` 必须与 [`DirectDomainFact::AuthenticationRejected`] 同口径:登录态失效重登 - /// 之后再发一次是有意义的,`recovery_hint` 也是这么写的。两套分类路径给出相反结论,会让同一 - /// 份事实的 `retryable` 取决于哪一层先认出它。 - fn is_retryable(&self) -> bool { - match self { - Self::ContextWindowExceeded | Self::RequestTooLarge | Self::Unauthorized => true, - Self::SessionBudgetExceeded - | Self::UsageLimitExceeded - | Self::StreamRequired - | Self::CyberPolicy - | Self::SandboxError - | Self::ThreadRollbackFailed - | Self::BadRequest - | Self::ActiveTurnNotSteerable - | Self::Other { .. } => false, - } - } -} - -/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 -/// -/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 -/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, -/// 枚举成员名与线上取值一一对应,改名即改协议。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnFailureKind { - /// 等待模型回执撞上上限。 - Timeout, - /// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。 - ModelFailed, - /// 执行通道断开。 - TransportFailed, - /// app-server 或上游明确拒绝了这次请求。 - RequestRejected, - /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 - EnvironmentNotReady, - /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 - TurnInterrupted, - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, -} - -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 -/// -/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 -/// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 -/// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectModelCallKind { - /// `LlmError::Timeout`。 - ResponseTimedOut { attempts: u32 }, - /// `LlmError::Connectivity`。 - ConnectionFailed { attempts: u32 }, - /// `LlmError::Transport`:通道关闭 / 收尾历史落盘失败等宿主侧收场。 - TransportBroken, - /// `LlmError::StreamUnavailable`。 - StreamUnavailable, - /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 - RequestRejected { - native: Option, - }, - /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 - UpstreamFailed { - status_code: u16, - native: Option, - }, - /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 - /// - /// 约定由 app-server 保证并有测试盯着: - /// `codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error`。 - PaidCreditsInsufficient, - /// `LlmError::EmptyResponse`。 - EmptyResponse, - /// `LlmError::Deserialize`。 - PayloadInvalid { - native: Option, - }, -} - -impl DirectModelCallKind { - /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 - fn wire_kind(&self) -> DirectTurnFailureKind { - match self { - Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout, - Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - DirectTurnFailureKind::TransportFailed - } - Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected, - Self::UpstreamFailed { .. } - | Self::PaidCreditsInsufficient - | Self::EmptyResponse - | Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed, - } - } - - /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 - fn is_model_repairable(&self) -> bool { - match self { - Self::ResponseTimedOut { .. } => false, - Self::ConnectionFailed { .. } => true, - // 通道关闭与收尾落盘失败:宿主自己写着"不能自动重放未确认操作",重试没有安全路径。 - Self::TransportBroken => false, - Self::StreamUnavailable => false, - Self::RequestRejected { native } => match native { - Some(native) => !native.is_terminal(), - None => true, - }, - // 认得出原生分类就按分类判;认不出(宿主自己构造的上游失败)就不猜:没有得到证据的 - // 上游故障不值得让模型再跑一轮。 - Self::UpstreamFailed { native, .. } => match native { - Some(native) => !native.is_terminal(), - None => false, - }, - Self::PaidCreditsInsufficient => false, - Self::EmptyResponse => false, - Self::PayloadInvalid { .. } => false, - } - } - - /// 用户重试这一轮有没有意义。 - fn is_retryable(&self) -> bool { - match self { - Self::ResponseTimedOut { .. } | Self::ConnectionFailed { .. } => true, - Self::TransportBroken | Self::StreamUnavailable => false, - Self::RequestRejected { native } => match native { - Some(native) => native.is_retryable(), - None => true, - }, - Self::UpstreamFailed { - status_code, - native, - } => match native { - Some(native) => native.is_retryable(), - None => *status_code >= 500, - }, - Self::PaidCreditsInsufficient => false, - Self::EmptyResponse => false, - Self::PayloadInvalid { .. } => false, - } - } - - /// 给用户看的稳定摘要:能一句话说清的才有,其余交给阶段兜底。 - fn public_summary(&self) -> Option<&'static str> { - match self { - Self::PaidCreditsInsufficient => Some("泥点余额不足"), - Self::ResponseTimedOut { .. } => Some("等待模型回执超时"), - Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - Some("执行通道未能建立或已断开") - } - Self::EmptyResponse => Some("模型未返回内容"), - Self::PayloadInvalid { .. } => Some("模型回执无法解析"), - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native - .as_ref() - .and_then(DirectCodexNativeKind::public_summary), - } - } - - /// 恢复建议:分类能直接给出动作的就给,给不出就交给阶段兜底。 - fn recovery_hint(&self) -> Option<&'static str> { - match self { - Self::PaidCreditsInsufficient => Some("泥点余额不足,请充值后发送“继续”"), - Self::ResponseTimedOut { .. } => { - Some("上游响应超时,请稍后重试;如持续失败请检查项目诊断") - } - Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - Some("执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断") - } - Self::EmptyResponse | Self::PayloadInvalid { .. } => { - Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") - } - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native - .as_ref() - .and_then(DirectCodexNativeKind::recovery_hint), - } - } -} - -/// 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnError { - // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── - /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 - ClientTurnIdMissing, - /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 - ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, - /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 - /// - /// 上限只落在宿主这一处(`MAX_PENDING_TURNS`),随载荷带出去,界面不自己数一份。 - QueueFull { limit: usize }, - /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 - ProjectRootUnanchored { cause: String }, - /// 项目目录不存在或不是绝对路径。 - ProjectRootUnusable, - /// 项目权限策略拒绝了这次调用;`policy_detail` 是策略层的原文(带被拒的点位)。 - PermissionRejected { policy_detail: String }, - /// 用户条目 / 创建类型本身不合法。 - InputRejected { detail: String }, - /// 结构化消息既没有正文也没有任何引用。 - ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 - /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 - EnvironmentNotReady { detail: String }, - /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 - HostStateUnavailable { detail: String }, - - // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── - /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 - ModelCallFailed { - kind: DirectModelCallKind, - detail: String, - }, - /// 执行通道断开;`diagnostic` 是连接终止时那份诊断(进程退出 / stderr 摘要)。 - TransportClosed { diagnostic: String }, - /// 等待模型回执撞上限。 - TimedOut { deadline: DirectTurnDeadline }, - /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 - TurnInterrupted { detail: String }, - /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: - /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 - ReviewRequired { detail: String }, - /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) - /// —— **控制流,不是失败**:与 [`DirectTurnError::ReviewRequired`] 同一族,只是产生点在收尾 - /// 阶段而不是交付复核。调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 - RepairRequired { detail: String }, - /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 - TurnFailed { - stage: DirectCodexFailureStage, - detail: String, - }, - /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, - /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 - TurnFailedUnclassified { detail: String }, -} - -/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 -/// -/// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 -/// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 -/// `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnEnqueueFailure { - /// 结构化变体:界面按 `error.type` 分流,不解析文案。 - pub(crate) error: DirectTurnError, - /// 可展示文案(`Display` 的唯一出口)。 - pub(crate) message: String, -} - -impl DirectTurnEnqueueFailure { - pub(crate) fn new(error: DirectTurnError) -> Self { - Self { - message: error.to_string(), - error, - } - } -} - -impl DirectTurnError { - /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 - /// - /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 - /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 - /// 判据按变体分,不看文案。 - /// - /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 - /// 就是同一件事留两份。 - pub(crate) fn is_reportable(&self) -> bool { - match self { - // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 - Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, - Self::ClientTurnIdMissing - | Self::ClientTurnIdMalformed { .. } - | Self::QueueFull { .. } - // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 - // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 - | Self::ProjectRootUnanchored { .. } - | Self::ProjectRootUnusable - | Self::PermissionRejected { .. } - | Self::InputRejected { .. } - | Self::ContentEmpty - | Self::ModelCallFailed { .. } - | Self::TransportClosed { .. } - | Self::TimedOut { .. } - | Self::TurnInterrupted { .. } - | Self::ReviewRequired { .. } - | Self::RepairRequired { .. } - | Self::TurnFailed { .. } - | Self::TurnFailedUnclassified { .. } => false, - } - } - - /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 - pub(crate) fn wire_kind(&self) -> Option { - match self { - Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), - Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed), - // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 - Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady), - Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout), - Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted), - Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => { - Some(DirectTurnFailureKind::ModelFailed) - } - _ => None, - } - } - - /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 - pub(crate) fn turn_failure_stage(&self) -> DirectCodexFailureStage { - match self { - Self::TurnFailed { stage, .. } => *stage, - _ => DirectCodexFailureStage::CodeGeneration, - } - } - - /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 - pub(crate) fn is_model_repairable(&self) -> bool { - match self { - Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), - // 阶段失败 / 桥变体:**认出是哪一类就拦**(产生层还没 typed 出口的深层事实才继续 - // 反馈)。已归类的都是模型改不动的事实——凭据 / 权限 / 额度 / 历史一致性与契约变化, - // 把同一份输入再跑一轮只会拿到同一结论;旧的字面量判据也是这个口径。 - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).is_none() - } - _ => false, - } - } - - /// 用户重试这一轮有没有意义。 - pub(crate) fn is_retryable(&self) -> bool { - match self { - Self::ModelCallFailed { kind, .. } => kind.is_retryable(), - Self::TransportClosed { .. } => false, - // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 - Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable) - } - _ => false, - } - } - - /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 - pub(crate) fn public_summary(&self) -> Option<&'static str> { - match self { - Self::ModelCallFailed { kind, .. } => kind.public_summary(), - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).and_then(DirectDomainFact::public_summary) - } - _ => None, - } - } - - /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 - pub(crate) fn recovery_hint(&self) -> Option<&'static str> { - match self { - Self::ModelCallFailed { kind, .. } => kind.recovery_hint(), - Self::TransportClosed { .. } => { - Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") - } - Self::TimedOut { .. } => { - Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") - } - Self::TurnInterrupted { .. } => { - Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") - } - Self::TurnFailed { stage, detail } => direct_code_failure_recovery_hint(*stage, detail), - // 桥变体没有阶段可依,按回合主体的默认段给建议。 - Self::TurnFailedUnclassified { detail } => { - direct_code_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, detail) - } - _ => None, - } - } - - /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** - /// - /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 - /// [`DirectCodexNativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 - pub(crate) fn from_model_call(error: &LlmError) -> Self { - let detail = error.to_string(); - let kind = match error { - LlmError::Timeout { attempts } => DirectModelCallKind::ResponseTimedOut { - attempts: *attempts, - }, - LlmError::Connectivity { attempts, .. } => DirectModelCallKind::ConnectionFailed { - attempts: *attempts, - }, - // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 - LlmError::Transport(_) => DirectModelCallKind::TransportBroken, - LlmError::StreamUnavailable => DirectModelCallKind::StreamUnavailable, - LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { - DirectModelCallKind::RequestRejected { - native: direct_codex_native_kind(&detail), - } - } - LlmError::Upstream { status_code, .. } if *status_code == 409 => { - DirectModelCallKind::PaidCreditsInsufficient - } - LlmError::Upstream { status_code, .. } => DirectModelCallKind::UpstreamFailed { - status_code: *status_code, - native: direct_codex_native_kind(&detail), - }, - LlmError::EmptyResponse => DirectModelCallKind::EmptyResponse, - LlmError::Deserialize(_) => DirectModelCallKind::PayloadInvalid { - native: direct_codex_native_kind(&detail), - }, - }; - Self::ModelCallFailed { kind, detail } - } - - /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 - pub(crate) fn turn_failed(stage: DirectCodexFailureStage, detail: impl Into) -> Self { - Self::TurnFailed { - stage, - detail: detail.into(), - } - } -} - -impl fmt::Display for DirectTurnError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ClientTurnIdMissing => formatter.write_str( - "Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份", - ), - Self::ClientTurnIdMalformed { - min_chars, - max_chars, - } => write!( - formatter, - "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - ), - Self::QueueFull { limit } => write!( - formatter, - "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" - ), - Self::ProjectRootUnanchored { cause } => { - write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") - } - Self::ProjectRootUnusable => { - formatter.write_str("当前项目目录不存在或不是绝对路径") - } - Self::PermissionRejected { policy_detail } => formatter.write_str(policy_detail), - Self::InputRejected { detail } - | Self::EnvironmentNotReady { detail } - | Self::HostStateUnavailable { detail } - | Self::ModelCallFailed { detail, .. } - | Self::TurnInterrupted { detail } - | Self::TurnFailed { detail, .. } - | Self::TurnFailedUnclassified { detail } => formatter.write_str(detail), - Self::ContentEmpty => formatter.write_str("聊天内容不能为空"), - Self::TransportClosed { diagnostic } => write!( - formatter, - "执行通道已断开,不能自动重放未确认操作:{diagnostic}" - ), - Self::TimedOut { deadline } => formatter.write_str(deadline.message()), - Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { - formatter.write_str(detail) - } - } - } -} - -/// 跨进程边界(`#[tauri::command]`)的序列化:字符串只在这里生成一次。 -impl From for String { - fn from(error: DirectTurnError) -> Self { - error.to_string() - } -} - -/// 桥:深层尚未 typed 的字符串错误落进 [`DirectTurnError::TurnFailedUnclassified`]。 -/// -/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 -impl From for DirectTurnError { - fn from(detail: String) -> Self { - Self::TurnFailedUnclassified { detail } - } -} - -impl From<&str> for DirectTurnError { - fn from(detail: &str) -> Self { - Self::TurnFailedUnclassified { - detail: detail.to_string(), - } - } -} - -/// 从原因文本里读出 app-server 写下的原生失败分类。 -/// -/// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 -fn direct_codex_native_kind(detail: &str) -> Option { - let rest = detail.split_once(DIRECT_CODEX_NATIVE_KIND_PREFIX)?.1; - let id = rest - .split(|character: char| character.is_whitespace()) - .next() - .unwrap_or_default(); - if id.is_empty() { - return None; - } - Some(DirectCodexNativeKind::from_id(id)) -} - -/// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 -/// -/// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 -/// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DirectDomainFact { - /// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。 - PaidCreditsInsufficient, - /// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。 - CredentialStorageUnprepared, - /// 本机私有凭据已创建但没保存住(`assets` 的私有凭据存储)。 - CredentialNotPersisted, - /// 本机还没有直连用的开发者 Key(`assets`)。 - LocalDeveloperKeyMissing, - /// 登录态失效 / 上游 401(`assets` / 项目权限读取)。 - AuthenticationRejected, - /// 账号没有访问该资源的权限 / 上游 403。 - PermissionDenied, - /// 本机私有凭据不可用(笼统的一条)。 - CredentialsUnavailable, - /// 项目对话历史注入载荷超限(`codex_app_server` 的历史注入前置校验)。 - HistoryInjectionOversize, - /// 项目对话历史存在本版本无法识别的记录(`direct_project_history`)。 - HistoryShapeUnsupported, - /// 另一个进程正持有历史追加锁(`project` 的追加锁)。 - HistoryContention, - /// 项目写锁争用(`project` 的写锁)。 - ProjectWriteLockContention, - /// 历史图集身份不唯一 / 不匹配 / 无可见像素(`direct_runtime` 的图集恢复前置校验)。 - ArtIdentityRejected, - /// 交付合同在恢复期间发生变化(`direct_runtime` 的图集恢复)。 - ContractChanged, - /// 本轮执行/返修预算已耗尽(`direct_execution`)。 - ValidationBudgetExhausted, - /// 同一输入的验证已受理(`direct_execution`)。 - ValidationAlreadyRunning, - /// 试玩次数上限(`direct_validation`)。 - PlaytestAttemptLimitExceeded, - /// 工具参数不合法(`direct_tool_bridge`)。 - ToolArgumentsInvalid, - /// 本轮被取消(用户终止 / 上游取消)。 - Cancelled, -} - -impl DirectDomainFact { - fn classify(detail: &str) -> Option { - let normalized = detail.to_ascii_lowercase(); - let contains = |marker: &str| { - if marker.chars().any(char::is_uppercase) { - detail.contains(marker) - } else { - normalized.contains(marker) - } - }; - // 顺序即优先级:更具体的事实先判,笼统的放后面。 - const CANDIDATES: &[(DirectDomainFact, &[&str])] = &[ - ( - DirectDomainFact::PaidCreditsInsufficient, - &[ - "泥点余额不足", - "可消费泥点不足", - "kind=mud-points-insufficient", - "insufficient_mud_points", - "insufficient-mud-points", - ], - ), - ( - DirectDomainFact::CredentialStorageUnprepared, - &["private-external-editor-credential-storage-preparation-failed"], - ), - ( - DirectDomainFact::CredentialNotPersisted, - &["private-external-editor-credential-persistence-failed"], - ), - ( - DirectDomainFact::LocalDeveloperKeyMissing, - &["本机陶泥儿开发者 Key"], - ), - ( - DirectDomainFact::AuthenticationRejected, - &["authentication-required", "unauthorized", "http 401"], - ), - ( - DirectDomainFact::PermissionDenied, - &["permission-denied", "http 403"], - ), - ( - DirectDomainFact::HistoryInjectionOversize, - &["历史注入载荷超过单行上限"], - ), - ( - DirectDomainFact::HistoryShapeUnsupported, - &[ - "DirectProject 历史记录类型无效", - "DirectProject 历史记录缺少 payload", - "解析 DirectProject 历史失败", - ], - ), - ( - DirectDomainFact::ProjectWriteLockContention, - &[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX], - ), - ( - DirectDomainFact::HistoryContention, - &[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER], - ), - ( - DirectDomainFact::ArtIdentityRejected, - &[ - "身份不唯一", - "身份不匹配", - "未找到身份完整的历史图集", - "没有可见像素", - ], - ), - (DirectDomainFact::ContractChanged, &["合同发生变化"]), - ( - DirectDomainFact::ValidationBudgetExhausted, - &["validation-budget-exhausted"], - ), - ( - DirectDomainFact::ValidationAlreadyRunning, - &["validation-already-running"], - ), - ( - DirectDomainFact::PlaytestAttemptLimitExceeded, - &["playtest-attempt-limit-exceeded"], - ), - (DirectDomainFact::ToolArgumentsInvalid, &["工具参数"]), - (DirectDomainFact::Cancelled, &["取消"]), - ( - DirectDomainFact::CredentialsUnavailable, - &["credential", "凭据"], - ), - ]; - CANDIDATES - .iter() - .find(|(_, markers)| markers.iter().any(|marker| contains(marker))) - .map(|(fact, _)| *fact) - } - - /// 用户重试这一轮有没有意义:同一份输入每次都会得到同一结论的事实不标可重试。 - fn is_retryable(self) -> bool { - match self { - Self::PaidCreditsInsufficient - | Self::CredentialStorageUnprepared - | Self::CredentialNotPersisted - | Self::HistoryInjectionOversize - | Self::HistoryShapeUnsupported - | Self::ArtIdentityRejected - | Self::ContractChanged - | Self::ValidationBudgetExhausted - | Self::ValidationAlreadyRunning - | Self::PlaytestAttemptLimitExceeded => false, - Self::LocalDeveloperKeyMissing - | Self::AuthenticationRejected - | Self::PermissionDenied - | Self::CredentialsUnavailable - | Self::HistoryContention - | Self::ProjectWriteLockContention - | Self::ToolArgumentsInvalid - | Self::Cancelled => true, - } - } - - /// 给用户看的稳定摘要:能一句话说清"哪里不对"的才有。 - fn public_summary(self) -> Option<&'static str> { - match self { - Self::PaidCreditsInsufficient => Some("泥点余额不足"), - Self::CredentialStorageUnprepared => { - Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据") - } - Self::CredentialNotPersisted => Some("本机开发者凭据已创建但未能安全保存"), - Self::HistoryInjectionOversize => Some("项目对话历史有单条记录超过注入上限"), - Self::LocalDeveloperKeyMissing - | Self::AuthenticationRejected - | Self::PermissionDenied - | Self::CredentialsUnavailable - | Self::HistoryShapeUnsupported - | Self::HistoryContention - | Self::ProjectWriteLockContention - | Self::ArtIdentityRejected - | Self::ContractChanged - | Self::ValidationBudgetExhausted - | Self::ValidationAlreadyRunning - | Self::PlaytestAttemptLimitExceeded - | Self::ToolArgumentsInvalid - | Self::Cancelled => None, - } - } - - /// 恢复建议:每条事实对应一个用户能做的动作。 - fn recovery_hint(self) -> &'static str { - match self { - Self::PaidCreditsInsufficient => "泥点余额不足,请充值后发送“继续”", - Self::CredentialStorageUnprepared => { - "请检查当前 Windows 用户对本机私有凭据目录的权限后重试" - } - Self::CredentialNotPersisted => { - "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试" - } - Self::LocalDeveloperKeyMissing => { - "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key" - } - Self::AuthenticationRejected => "登录态可能已失效,请重新登录陶泥儿后重试", - Self::PermissionDenied => { - "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试" - } - Self::CredentialsUnavailable => "请检查本机开发者凭据后重试", - Self::HistoryInjectionOversize => { - "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求" - } - Self::HistoryShapeUnsupported => { - "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" - } - Self::HistoryContention => { - "另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求" - } - Self::ProjectWriteLockContention => "当前项目仍有写入正在结束,请稍后再次发送该需求", - Self::ArtIdentityRejected => { - "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集" - } - Self::ContractChanged => "本轮产物合同在恢复期间发生变化,请重新发送该需求", - Self::ValidationBudgetExhausted => { - "本轮验证预算已耗尽,请发送“继续”开始下一批返修" - } - Self::ValidationAlreadyRunning => "同一输入的验证已受理,请等待原执行结束后再试", - Self::PlaytestAttemptLimitExceeded => { - "试玩次数已达上限,请按项目诊断修复后再次发送需求" - } - Self::ToolArgumentsInvalid => "工具参数不合法,请重试;如持续失败请检查项目诊断", - Self::Cancelled => "本轮已取消,请重新发送该需求", - } - } -} - -/// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 -fn direct_code_failure_recovery_hint( - stage: DirectCodexFailureStage, - detail: &str, -) -> Option<&'static str> { - if let Some(fact) = DirectDomainFact::classify(detail) { - return Some(fact.recovery_hint()); - } - Some(match stage { - DirectCodexFailureStage::ArtPreparation => { - "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" - } - DirectCodexFailureStage::CodeGeneration => { - "Codex 未完成本轮代码修改,请检查运行时配置后重试" - } - DirectCodexFailureStage::BrowserValidation => { - "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" - } - DirectCodexFailureStage::VersionRegistration => { - "产物尚未安全登记为版本,请检查项目目录后重试" - } - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。 - #[test] - fn only_host_and_environment_rejections_are_reportable() { - assert!(DirectTurnError::EnvironmentNotReady { - detail: "Codex app-server 启动失败".into(), - } - .is_reportable()); - assert!(DirectTurnError::HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".into(), - } - .is_reportable()); - assert!(!DirectTurnError::ProjectRootUnanchored { - cause: "拒绝访问".into(), - } - .is_reportable()); - assert!(!DirectTurnError::ContentEmpty.is_reportable()); - assert!(!DirectTurnError::ProjectRootUnusable.is_reportable()); - assert!(!DirectTurnError::ClientTurnIdMissing.is_reportable()); - assert!(!DirectTurnError::PermissionRejected { - policy_detail: "项目权限策略拒绝执行:conversation.write".into(), - } - .is_reportable()); - // 回合级失败在上游已经写过诊断。 - assert!( - !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") - .is_reportable() - ); - } - - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 - #[test] - fn boundary_serialization_uses_display() { - let wire: String = DirectTurnError::ContentEmpty.into(); - assert_eq!(wire, "聊天内容不能为空"); - let wire: String = DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::TurnHardLimit, - } - .into(); - assert_eq!( - wire, - "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。" - ); - let wire: String = DirectTurnError::TransportClosed { - diagnostic: "执行通道已断开".into(), - } - .into(); - assert_eq!( - wire, - "执行通道已断开,不能自动重放未确认操作:执行通道已断开" - ); - } - - /// 载荷 kind 与改造前的 `direct_turn_failure_kind(&LlmError)` 逐条对齐。 - #[test] - fn wire_kind_matches_the_previous_llm_error_classification() { - let cases = [ - ( - LlmError::Timeout { attempts: 3 }, - DirectTurnFailureKind::Timeout, - ), - ( - LlmError::InvalidConfig("missing key".into()), - DirectTurnFailureKind::RequestRejected, - ), - ( - LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), - DirectTurnFailureKind::RequestRejected, - ), - ( - LlmError::Connectivity { - attempts: 2, - message: "Codex app-server 连接失败".into(), - }, - DirectTurnFailureKind::TransportFailed, - ), - ( - LlmError::Transport("DirectProject 收尾历史失败".into()), - DirectTurnFailureKind::TransportFailed, - ), - ( - LlmError::StreamUnavailable, - DirectTurnFailureKind::TransportFailed, - ), - ( - LlmError::Upstream { - status_code: 502, - message: "上游 502".into(), - }, - DirectTurnFailureKind::ModelFailed, - ), - (LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed), - ( - LlmError::Deserialize("bad payload".into()), - DirectTurnFailureKind::ModelFailed, - ), - ]; - for (error, expected) in cases { - let projected = DirectTurnError::from_model_call(&error); - assert_eq!(projected.wire_kind(), Some(expected), "{error:?}"); - assert_eq!(projected.to_string(), error.to_string(), "{error:?}"); - } - } - - /// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。 - /// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。 - #[test] - fn failure_kind_wire_values_are_stable() { - let cases = [ - (DirectTurnFailureKind::Timeout, "timeout"), - (DirectTurnFailureKind::ModelFailed, "model-failed"), - (DirectTurnFailureKind::TransportFailed, "transport-failed"), - (DirectTurnFailureKind::RequestRejected, "request-rejected"), - ( - DirectTurnFailureKind::EnvironmentNotReady, - "environment-not-ready", - ), - (DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"), - (DirectTurnFailureKind::HostDropped, "host-dropped"), - ]; - for (kind, wire) in cases { - assert_eq!(serde_json::to_value(kind).expect("serialize"), wire); - assert_eq!( - serde_json::from_str::(&format!("\"{wire}\"")) - .expect("deserialize"), - kind - ); - } - } - - /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 - #[test] - fn native_kind_is_read_from_the_structured_prefix_only() { - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), - )); - match projected { - DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( - kind, - DirectModelCallKind::RequestRejected { - native: Some(DirectCodexNativeKind::ContextWindowExceeded) - } - ), - other => panic!("expected a model call failure, got {other:?}"), - } - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:some-future-kind".into(), - )); - match projected { - DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( - kind, - DirectModelCallKind::RequestRejected { - native: Some(DirectCodexNativeKind::Other { - kind: "some-future-kind".into() - }) - } - ), - other => panic!("expected a model call failure, got {other:?}"), - } - // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "Codex app-server turn 已中断".into(), - )); - match projected { - DirectTurnError::ModelCallFailed { kind, .. } => { - assert_eq!(kind, DirectModelCallKind::RequestRejected { native: None }) - } - other => panic!("expected a model call failure, got {other:?}"), - } - } - - /// 上游 409 是平台约定的泥点余额不足,进专属分类。 - #[test] - fn upstream_payment_refusal_is_its_own_kind() { - let projected = DirectTurnError::from_model_call(&LlmError::Upstream { - status_code: 409, - message: "泥点余额不足".into(), - }); - match &projected { - DirectTurnError::ModelCallFailed { kind, .. } => { - assert_eq!(kind, &DirectModelCallKind::PaidCreditsInsufficient) - } - other => panic!("expected a model call failure, got {other:?}"), - } - assert_eq!(projected.public_summary(), Some("泥点余额不足")); - assert_eq!( - projected.recovery_hint(), - Some("泥点余额不足,请充值后发送“继续”") - ); - assert!(!projected.is_retryable()); - assert!(!projected.is_model_repairable()); - } - - /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 - #[test] - fn terminal_native_kinds_are_not_fed_back_to_the_model() { - let terminal = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".into(), - )); - assert!(!terminal.is_model_repairable()); - let repairable = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:other detail=fields=codexErrorInfo".into(), - )); - assert!(repairable.is_model_repairable()); - // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 - assert!(DirectTurnError::from_model_call(&LlmError::Connectivity { - attempts: 2, - message: "连接失败".into(), - }) - .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Transport( - "DirectProject 收尾历史失败".into() - )) - .is_model_repairable()); - assert!( - !DirectTurnError::from_model_call(&LlmError::Timeout { attempts: 1 }) - .is_model_repairable() - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs deleted file mode 100644 index 61ca56880..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己 -//! 提前收场时补一条失败终态。 -//! -//! 这个模块只有三件事,别再往里加第四件: -//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; -//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 -//! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `thread_manager::dispatch` 的放行占用对象里: -//! 这个模块只负责"什么算失败、原因怎么写"。 -//! -//! 失败载荷的**形状**属于线上协议,定义在 `thread_manager::wire`(`DirectTurnFailure`); -//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 -//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", -//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 - -use std::path::Path; - -use super::{ - redact_agent_runtime_error, DirectTurnError, DirectTurnFailure, DirectTurnFailureKind, - ThreadEvent, -}; - -/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 -/// 把整段上游报文塞进事件队列。 -const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; - -/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 -const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = - "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; - -/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 -pub(crate) struct DirectTurnTerminal { - pub(crate) status: String, - pub(crate) failure: Option, -} - -impl DirectTurnTerminal { - /// 一次成功终态:只带 `status="completed"`,不带失败载荷。 - /// - /// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后, - /// 线程仍被放行占用,必须由放行侧补写这条终态。 - pub(crate) fn completed() -> Self { - Self { - status: "completed".to_string(), - failure: None, - } - } - - /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { - let event = match self.failure { - Some(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), - None => ThreadEvent::turn_completed(self.status, completed_at), - }; - event.with_user_item_id(user_item_id) - } -} - -/// 拿这一轮的**事实**判定终态。判据按优先级: -/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), -/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; -/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: -/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来; -/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 -/// 阶段的账本读不出来时只有它可用。 -/// -/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在 -/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, -/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", -/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 -/// -/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏 -/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。 -pub(crate) fn direct_turn_terminal( - session_status: &str, - collect_outcome: Result<&str, DirectTurnError>, - host_failure: Option<&DirectTurnError>, - history_root: &Path, -) -> DirectTurnTerminal { - let failure = match (host_failure, collect_outcome) { - (Some(failure), _) => Some(failure.clone()), - (None, Err(error)) => Some(error.clone()), - // 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去, - // 不能让界面停在"已结束、没原因"。 - (None, Ok(report)) if session_status == "failed" => { - Some(DirectTurnError::TurnFailedUnclassified { - detail: report.to_string(), - }) - } - (None, Ok(_)) => None, - }; - match failure { - Some(failure) => DirectTurnTerminal::failed(history_root, &failure), - None => DirectTurnTerminal { - status: session_status.to_string(), - failure: None, - }, - } -} - -impl DirectTurnTerminal { - /// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。 - pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self { - Self { - status: "failed".to_string(), - failure: Some(DirectTurnFailure::new( - failure - .wire_kind() - .unwrap_or(DirectTurnFailureKind::ModelFailed), - redact_agent_runtime_error( - history_root, - &failure.to_string(), - DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, - ), - )), - } - } - - /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 - /// - /// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。 - pub(crate) fn host_dropped() -> Self { - Self { - status: "failed".to_string(), - failure: Some(DirectTurnFailure::new( - DirectTurnFailureKind::HostDropped, - DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), - )), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{consume_thread, subscribe_thread}; - use platform_llm::LlmError; - - fn history_root() -> std::path::PathBuf { - std::path::PathBuf::from("/tmp/direct-turn-failure-test") - } - - /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 - #[test] - fn non_failure_terminals_keep_the_session_status() { - for status in ["completed", "interrupted", "aborted"] { - let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root()); - assert!(terminal.failure.is_none(), "{status} 不该带失败载荷"); - assert_eq!(terminal.status, status); - } - } - - /// 拿得到错误:分类与原因都取自错误。 - #[test] - fn collect_error_becomes_a_failure_terminal() { - let error = DirectTurnError::from_model_call(&LlmError::Transport( - "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), - )); - let terminal = direct_turn_terminal("completed", Err(error), None, &history_root()); - let failure = terminal - .failure - .expect("transport error must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); - assert!(failure.message.contains("收尾历史失败")); - } - - /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed - /// 错误(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 - /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, - /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 - #[test] - fn projected_native_failure_outranks_the_interrupted_session_status() { - let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".into(), - )); - let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); - let failure = terminal.failure.expect("native failure must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected); - assert_eq!( - failure.message, - "codex-app-server-error:context-window-exceeded" - ); - } - - /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, - /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 - #[test] - fn unreadable_session_ledger_still_reports_a_payload() { - let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root()); - assert_eq!(terminal.status, "failed"); - let failure = terminal - .failure - .expect("unreadable ledger must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed); - assert_eq!(failure.message, "报告"); - } - - /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 - #[test] - fn host_recorded_failure_outranks_every_other_source() { - let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ -stderrClass=nonempty;stderrBytes=1000"; - let host_failure = DirectTurnError::TransportClosed { - diagnostic: diagnostic.to_string(), - }; - let terminal = direct_turn_terminal( - "interrupted", - Ok("执行连接已结束,正在核对自有子进程与在途操作。"), - Some(&host_failure), - &history_root(), - ); - let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); - assert!(failure.message.contains("SIGKILL")); - assert!(!failure.message.contains("正在核对自有子进程")); - - // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 - let error = DirectTurnError::from_model_call(&LlmError::Transport( - "DirectProject 收尾历史失败".into(), - )); - let host_failure = DirectTurnError::TurnInterrupted { - detail: "本轮模型执行被中断".into(), - }; - let terminal = direct_turn_terminal( - "interrupted", - Err(error), - Some(&host_failure), - &history_root(), - ); - let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted); - assert!(failure.message.contains("本轮模型执行被中断")); - } - - /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - #[test] - fn terminal_event_carries_the_payload_and_the_opening_identity() { - let error = DirectTurnError::from_model_call(&LlmError::Upstream { - status_code: 502, - message: "上游 502".into(), - }); - let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); - let event = failing.event(2_000, Some("direct-codex:turn-1:user")); - assert_eq!( - event.failure().map(|failure| failure.kind), - Some(DirectTurnFailureKind::ModelFailed) - ); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!(event.at(), Some(2_000)); - - let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); - let event = quiet.event(3_000, None); - assert!(event.failure().is_none()); - assert!(matches!( - event, - ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" - )); - } - - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 - #[test] - fn host_dropped_terminal_only_carries_the_classification() { - let terminal = DirectTurnTerminal::host_dropped(); - assert_eq!(terminal.status, "failed"); - let failure = terminal.failure.expect("host-dropped must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert!(!failure.message.trim().is_empty()); - } - - #[test] - fn completed_terminal_carries_no_failure_payload() { - let terminal = DirectTurnTerminal::completed(); - assert_eq!(terminal.status, "completed"); - assert!(terminal.failure.is_none()); - assert!(matches!( - terminal.event(1_700_000_000_000, Some("item-1")), - ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" - )); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index d2b6f847b..a42aed1b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -395,6 +395,137 @@ async fn finish_async( .map_err(|_| "验证回执任务退出".to_string())? } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct GameplayViewportEvidence { + viewport: crate::browser::BrowserValidationViewport, + passed: bool, + diagnostics: Vec, + assertions: Vec, + initial_phase: Option, + initial_sequence: Option, + initial_level: Option, + final_phase: Option, + final_sequence: Option, + final_level: Option, +} + +/// 只投影模型修复所需的玩法事实;完整报告继续留在宿主私有目录。 +pub(crate) fn browser_tool_result( + root: &Path, + evidence: &crate::browser::BrowserValidationResult, + scenario: Option, + sequence: usize, +) -> Value { + use crate::browser::BrowserValidationViewport::{Desktop, Mobile}; + + let gameplay_results = if scenario.is_some() { + [Desktop, Mobile] + .into_iter() + .map(|viewport| { + let result = evidence + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .map(|entry| &entry.result); + GameplayViewportEvidence { + viewport, + passed: result.is_some_and(|result| result.passed), + diagnostics: result + .map(|result| { + result + .diagnostics + .iter() + .take(4) + .map(|message| truncate_agent_runtime_text(message, 511)) + .collect() + }) + .unwrap_or_else(|| { + vec![prompt_text!("direct.browser.gameplayMissing").into()] + }), + assertions: result + .map(|result| result.assertions.clone()) + .unwrap_or_default(), + initial_phase: result.and_then(|result| result.initial_phase), + initial_sequence: result.and_then(|result| result.initial_sequence), + initial_level: result.and_then(|result| result.initial_level), + final_phase: result.and_then(|result| result.final_phase), + final_sequence: result.and_then(|result| result.final_sequence), + final_level: result.and_then(|result| result.final_level), + } + }) + .collect::>() + } else { + Vec::new() + }; + let gameplay_summary = if scenario.is_none() { + prompt_text!("direct.browser.gameplayNotRun").to_string() + } else { + let lines = gameplay_results + .iter() + .map(|result| { + format!( + prompt_text!("direct.browser.gameplayViewport"), + viewport = match result.viewport { + Desktop => "desktop", + Mobile => "mobile", + }, + passed = result.passed, + diagnostic = result + .diagnostics + .first() + .map(String::as_str) + .unwrap_or(prompt_text!("direct.browser.gameplayNoDiagnostic")), + assertion = result + .assertions + .iter() + .find(|assertion| !assertion.passed) + .map(|assertion| assertion.name.as_str()) + .unwrap_or(prompt_text!("direct.browser.gameplayNoUnmetAssertion")), + ) + }) + .collect::>(); + format!( + prompt_text!("direct.browser.gameplayEvidence"), + lines.join("\n") + ) + }; + let summary = format!( + "{}\n{}", + render_direct_browser_tool_evidence(root, evidence, sequence), + gameplay_summary + ); + let screenshots: Vec<_> = evidence + .viewport_results + .iter() + .filter_map(|viewport| { + viewport + .screenshot_path + .strip_prefix(root) + .ok() + .map(|path| path.to_string_lossy().replace('\\', "/")) + }) + .collect(); + let passed = evidence.passed + && scenario.is_none_or(|scenario| { + crate::browser::required_viewport_playtests_passed( + scenario, + &evidence.viewport_playtests, + ) + }); + json!({ + "kind":"browser", "mode":if scenario.is_some() {"gameplay"} else {"visual"}, + "scenario":scenario, "passed":passed, + "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), + "summary":summary, "screenshots":screenshots, + "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|path|path.to_string_lossy().replace('\\', "/")), + "visualViewports":["desktop","mobile"], + "gameplayViewports":evidence.viewport_playtests.iter().map(|entry|entry.viewport).collect::>(), + "gameplayResults":gameplay_results, + "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"} + }) +} + pub(super) async fn run_browser(root: &Path, arguments: &Value) -> Result { run_browser_with_budget(root, arguments, true).await } @@ -466,33 +597,9 @@ async fn run_browser_with_budget( .await; let (result, passed) = match evidence { Ok(evidence) => { - let screenshots: Vec<_> = evidence - .viewport_results - .iter() - .filter_map(|v| { - v.screenshot_path - .strip_prefix(root) - .ok() - .map(|p| p.to_string_lossy().replace('\\', "/")) - }) - .collect(); - let gameplay_passed = scenario.is_none_or(|scenario| { - crate::browser::required_viewport_playtests_passed( - scenario, - &evidence.viewport_playtests, - ) - }); - let passed = evidence.passed && gameplay_passed; - let summary = render_direct_browser_tool_evidence(root, &evidence, sequence); - ( - json!({"kind":"browser", "mode":input.mode, "scenario":scenario, - "scenarioFingerprint":scenario.map(crate::browser::browser_playtest_scenario_fingerprint), "summary":summary, "screenshots":screenshots, - "reportPath":evidence.evidence.report_path.strip_prefix(root).ok().map(|p|p.to_string_lossy().replace('\\', "/")), - "visualViewports":["desktop","mobile"], - "gameplayViewports":evidence.viewport_playtests.iter().map(|v|v.viewport).collect::>(), - "coverage":if scenario.is_some() {"dual-viewport-fixed-scenario"} else {"visual-only"}}), - passed, - ) + let result = browser_tool_result(root, &evidence, scenario, sequence); + let passed = result["passed"] == true; + (result, passed) } Err(error) => ( json!({"mode":input.mode,"needsReconciliation":error.starts_with("browser-cleanup-unconfirmed:"),"error":truncate_agent_runtime_text(&error, 1800)}), @@ -599,9 +706,147 @@ pub(super) async fn run_command(root: &Path, arguments: &Value) -> Result crate::browser::BrowserValidationResult { + let mut viewports = Vec::new(); + let mut playtests = Vec::new(); + for viewport in ["desktop", "mobile"] { + viewports.push(json!({ + "viewport":viewport, "width":1280, "height":720, + "finalUrl":"http://127.0.0.1/", "title":"fixture", "readyState":"complete", + "visibleTextSummary":"", "visibleTextCharacterCount":0, "domCharacterCount":0, + "expectedText":[], "consoleErrors":[], "consoleWarnings":[], "exceptions":[], + "failedRequests":[], "canvases":[], "diagnostics":[], "passed":true, + "blockedPopupCount":0, "blockedDialogCount":0, "blockedDownloadCount":0, + "blockedPermissionCount":0, "blockedServiceWorkerCount":0, + "screenshotPath":root.join(format!("{viewport}.png")) + })); + playtests.push(json!({"viewport":viewport, "result":{ + "scenario":"generic-v1", "scenarioFingerprint":"fixture", "passed":true, + "initialSequence":0, "initialPhase":"ready", "initialLevel":1, + "finalSequence":3, "finalPhase":"ready", "finalLevel":1, + "assertions":[ + {"name":"start-control-clicked", "passed":true}, + {"name":"start-phase-playing", "passed":true}, + {"name":"restart-control-clicked", "passed":true} + ], "diagnostics":[] + }})); + } + serde_json::from_value(json!({ + "schemaVersion":"browser-validation.v1", "url":"http://127.0.0.1/", + "browser":{"kind":"chrome", "product":"fixture", "protocolVersion":"1.3"}, + "passed":true, "viewportResults":viewports, "viewportPlaytests":playtests, + "diagnostics":[], "completedAtUnixMs":1, + "evidence":{"root":root, "reportPath":root.join("validation.json")} + })) + .unwrap() + } + + pub(crate) fn failed_browser_evidence_fixture( + root: &Path, + ) -> crate::browser::BrowserValidationResult { + let mut evidence = browser_evidence_fixture(root); + evidence.passed = false; + for (index, entry) in evidence.viewport_playtests.iter_mut().enumerate() { + entry.result.passed = false; + entry.result.final_phase = Some(crate::browser::BrowserPlaytestPhase::Ready); + entry.result.final_sequence = Some(index as u64); + entry.result.diagnostics = vec![if index == 0 { + "固定试玩控件 start 处于 disabled 状态".into() + } else { + "generic-v1 start 后必须进入 playing".into() + }]; + for (assertion_index, assertion) in entry.result.assertions.iter_mut().enumerate() { + assertion.passed = index == 1 && assertion_index == 0; + } + } + evidence + } + + #[test] + fn browser_tool_projection_distinguishes_gameplay_success_visual_only_and_missing_viewport() { + use crate::browser::BrowserPlaytestScenario::GenericV1; + let root = tempfile::tempdir().unwrap(); + for mode in ["gameplay", "visual", "missing-mobile"] { + let mut evidence = browser_evidence_fixture(root.path()); + let scenario = if mode == "visual" { + evidence.viewport_playtests.clear(); + None + } else { + Some(GenericV1) + }; + if mode == "missing-mobile" { + evidence.viewport_playtests.pop(); + evidence.passed = false; + } + let receipt = browser_tool_result(root.path(), &evidence, scenario, 1); + let summary = receipt["summary"].as_str().unwrap(); + assert_eq!(receipt["passed"], mode != "missing-mobile"); + if mode == "visual" { + assert_eq!(receipt["gameplayResults"], json!([])); + assert!(summary + .lines() + .any(|line| line.starts_with("玩法检查:未执行"))); + assert_eq!(receipt["coverage"], "visual-only"); + continue; + } + for (index, viewport) in ["desktop", "mobile"].iter().enumerate() { + let result = &receipt["gameplayResults"][index]; + assert_eq!(result["viewport"], *viewport); + if mode == "missing-mobile" && index == 1 { + assert_eq!(result["passed"], false); + assert!(result["diagnostics"][0].as_str().unwrap().contains("缺失")); + assert!(summary.contains("mobile: gameplayPassed=false")); + assert_eq!(result["assertions"], json!([])); + assert_eq!(result["finalPhase"], Value::Null); + } else { + assert_eq!(result["passed"], true); + assert_eq!(result["diagnostics"], json!([])); + assert!(result["assertions"] + .as_array() + .unwrap() + .iter() + .all(|a| a["passed"] == true)); + assert!(summary.contains(&format!("{viewport}: gameplayPassed=true"))); + } + } + } + } + + #[test] + fn browser_tool_projection_bounds_diagnostics_without_losing_either_blocker() { + let root = tempfile::tempdir().unwrap(); + let mut evidence = failed_browser_evidence_fixture(root.path()); + for viewport in &mut evidence.viewport_playtests { + viewport + .result + .diagnostics + .extend((0..8).map(|_| "额外诊断".repeat(200))); + } + let receipt = browser_tool_result( + root.path(), + &evidence, + Some(crate::browser::BrowserPlaytestScenario::GenericV1), + 1, + ); + for (index, entry) in evidence.viewport_playtests.iter().enumerate() { + let diagnostics = receipt["gameplayResults"][index]["diagnostics"] + .as_array() + .unwrap(); + assert_eq!(diagnostics.len(), 4); + assert_eq!(diagnostics[0], entry.result.diagnostics[0]); + assert!(diagnostics + .iter() + .all(|d| d.as_str().unwrap().chars().count() <= 512)); + assert!(receipt["summary"] + .as_str() + .unwrap() + .contains(&entry.result.diagnostics[0])); + } + } + fn project() -> (tempfile::TempDir, std::path::PathBuf) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index dc68f3f86..245491074 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1555,69 +1555,103 @@ pub(crate) async fn prepare_external_canvas_generation_context( write_external_canvas_binding_partial(root, &mut partial)?; } - let asset_folder_id = if let Some(asset_folder_id) = partial.remote_asset_folder_id.clone() { - asset_folder_id - } else { - let library_payload = external_editor_json_request( + let reusable_folder_id = partial.remote_asset_folder_id.clone().or_else(|| { + existing_binding + .as_ref() + .filter(|binding| binding.remote_project_id == project_id) + .map(|binding| binding.remote_asset_folder_id.clone()) + }); + let asset_folder_id = if let Some(asset_folder_id) = reusable_folder_id { + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + let folder_route = format!( + "/api/external/v1/editor/assets/folders/{}", + percent_encode_query_component(&asset_folder_id) + ); + access.validate_frozen_session()?; + let response = crate::http_client::with_agc_main_site_marker( client .get(format!( "{}{}", access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/library") + access.api_route(&folder_route) )) .bearer_auth(access.bearer_token()), - "读取外部素材库", + ) + .send() + .await + .map_err(|error| { + format!( + "读取绑定素材目录失败:{}", + describe_external_request_failure(&error) + ) + })?; + access.validate_frozen_session()?; + let status = response.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err("绑定素材目录不存在,无法继续生成".to_string()); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); + return Err(format_external_http_error( + "读取绑定素材目录", + status, + &body, + )); + } + let payload = response + .json::() + .await + .map_err(|error| { + format!( + "解析绑定素材目录响应失败:{}", + describe_external_request_failure(&error) + ) + })?; + access.validate_frozen_session()?; + let response_folder_id = external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "绑定素材目录响应缺少 folder.folderId".to_string())?; + if response_folder_id != asset_folder_id { + return Err("绑定素材目录响应身份与请求不一致".to_string()); + } + asset_folder_id + } else { + access.validate_frozen_session()?; + let payload = external_editor_json_request( + client + .post(format!( + "{}{}", + access.api_base_url(), + access.api_route("/api/external/v1/editor/assets/folders") + )) + .bearer_auth(access.bearer_token()) + .header( + "Idempotency-Key", + format!("game-creator-folder-{binding_key}"), + ) + .json(&serde_json::json!({ "label": canvas_name })), + "创建外部素材库目录", ) .await?; - access.validate_frozen_session()?; - let folders = external_editor_response_data(&library_payload) - .get("library") - .and_then(|library| library.get("folders")) - .and_then(serde_json::Value::as_array) - .ok_or_else(|| "外部素材库响应缺少 library.folders".to_string())?; - match existing_binding.as_ref().and_then(|binding| { - (binding.remote_project_id == project_id - && folders.iter().any(|folder| { - json_string_field(folder, "folderId").as_deref() - == Some(binding.remote_asset_folder_id.as_str()) - })) - .then(|| binding.remote_asset_folder_id.clone()) - }) { - Some(asset_folder_id) => asset_folder_id, - None => { - access.validate_frozen_session()?; - let payload = external_editor_json_request( - client - .post(format!( - "{}{}", - access.api_base_url(), - access.api_route("/api/external/v1/editor/assets/folders") - )) - .bearer_auth(access.bearer_token()) - .header( - "Idempotency-Key", - format!("game-creator-folder-{binding_key}"), - ) - .json(&serde_json::json!({ "label": canvas_name })), - "创建外部素材库目录", - ) - .await?; - let post_response_session = access.validate_frozen_session(); - let asset_folder_id = external_editor_response_data(&payload) - .get("folder") - .and_then(|folder| json_string_field(folder, "folderId")) - .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())?; - validate_external_canvas_binding_partial_id( - &asset_folder_id, - "External Editor remote assetFolderId", - )?; - partial.remote_asset_folder_id = Some(asset_folder_id.clone()); - partial.updated_at = unix_timestamp(); - write_external_canvas_binding_partial(root, &mut partial)?; - post_response_session?; - asset_folder_id - } - } + let post_response_session = access.validate_frozen_session(); + let asset_folder_id = external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "创建外部素材库目录响应缺少 folderId".to_string())?; + validate_external_canvas_binding_partial_id( + &asset_folder_id, + "External Editor remote assetFolderId", + )?; + partial.remote_asset_folder_id = Some(asset_folder_id.clone()); + partial.updated_at = unix_timestamp(); + write_external_canvas_binding_partial(root, &mut partial)?; + post_response_session?; + asset_folder_id }; validate_external_canvas_binding_partial_id( &asset_folder_id, @@ -8734,6 +8768,16 @@ mod canvas_generation_tests { Err(error) => panic!("accept standalone generation request: {error}"), }; let request = read_test_http_request(&mut stream); + if request + .starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": {"folderId": "manual-test-assets"}}}), + ); + continue; + } request_sender .send(request) .expect("capture standalone generation request"); @@ -8914,12 +8958,14 @@ mod canvas_generation_tests { }), ); false - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { + } else if request + .starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") + { write_test_json_response( &mut stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{"folderId": "manual-test-assets"}]}} + "data": {"folder": {"folderId": "manual-test-assets"}} }), ); false @@ -9289,15 +9335,15 @@ mod canvas_generation_tests { ); return; } - if request.starts_with("GET /api/external/v1/editor/assets/library ") { + if request.starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") { write_test_json_response( stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{ + "data": {"folder": { "folderId": "manual-test-assets", "label": "并发手工生成素材", - }]}} + }} }), ); return; @@ -9726,6 +9772,229 @@ mod canvas_generation_tests { ); } + #[tokio::test] + async fn external_canvas_context_confirms_bound_and_partial_folders_without_library_or_rebuild() + { + for use_partial in [false, true] { + for (status, returned_id, expected_error) in [ + ("200 OK", "manual-test-assets", None), + ("404 Not Found", "", Some("绑定素材目录不存在")), + ("401 Unauthorized", "", Some("HTTP 401")), + ("403 Forbidden", "", Some("HTTP 403")), + ("500 Internal Server Error", "", Some("HTTP 500")), + ("timeout", "", Some("请求超时")), + ("200 OK", "other-folder", Some("响应身份与请求不一致")), + ] { + let temporary = tempfile::tempdir().expect("create folder confirmation project"); + let root = temporary.path(); + init_local_game_project_at(root, "folder-confirmation-local", "目录身份确认测试") + .expect("initialize folder confirmation project"); + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .expect("bind folder confirmation fixture"); + listener + .set_nonblocking(true) + .expect("set folder fixture nonblocking"); + let base_url = + format!("http://{}", listener.local_addr().expect("fixture address")); + let access = ExternalEditorBindingAccess::for_developer( + &base_url, + "folder-confirmation-key", + ) + .expect("create folder confirmation access"); + install_test_external_project_binding(root, &base_url, "folder-confirmation-key"); + let principal = + external_editor_binding_principal(&access).expect("folder principal"); + let binding = read_external_editor_project_binding_at( + root, + "folder-confirmation-local", + &principal, + ) + .expect("read original binding") + .expect("original binding exists"); + if !use_partial { + remove_external_canvas_binding_partial(root, &binding.binding_key_sha256) + .expect("prepare completed binding"); + } + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => panic!("accept folder confirmation request: {error}"), + }; + let request = read_test_http_request(&mut stream); + requests.push(request.clone()); + if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") + { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": [{"projectId": "manual-test-canvas"}]}}), + ); + } else if request.starts_with( + "GET /api/external/v1/editor/assets/folders/manual-test-assets ", + ) { + if status == "timeout" { + std::thread::sleep(Duration::from_millis(200)); + continue; + } + // Empty folders require no assets or library snapshot to be usable. + write_test_json_response( + &mut stream, + status, + &serde_json::json!({"data": {"folder": {"folderId": returned_id}}, "error": {"message": "folder fixture rejection"}}), + ); + } else if request.starts_with("GET /api/external/v1/editor/assets/library ") + { + // An unavailable, slow account library cannot block identity confirmation. + std::thread::sleep(Duration::from_millis(200)); + } else { + write_test_json_response( + &mut stream, + "500 Internal Server Error", + &serde_json::json!({"error": {"message": "unexpected folder rebuild"}}), + ); + } + } + requests + }); + let client = reqwest::Client::builder() + .timeout(if status == "timeout" { + Duration::from_millis(100) + } else { + Duration::from_secs(2) + }) + .build() + .expect("build folder confirmation client"); + let result = + prepare_external_canvas_generation_context(root, &client, &access).await; + stop_sender + .send(()) + .expect("stop folder confirmation fixture"); + let requests = server.join().expect("join folder confirmation fixture"); + if let Some(expected_error) = expected_error { + let error = + result.expect_err("directory confirmation failure must stop preparation"); + assert!( + error.contains(expected_error), + "partial={use_partial}, status={status}: {error}" + ); + assert_eq!( + read_external_editor_project_binding_at( + root, + "folder-confirmation-local", + &principal + ) + .expect("reread unchanged binding"), + Some(binding) + ); + } else { + assert_eq!( + result + .expect("empty bound directory is usable") + .asset_folder_id, + "manual-test-assets" + ); + } + assert_eq!( + requests.len(), + if use_partial { 1 } else { 2 }, + "{requests:?}" + ); + assert!( + requests.iter().all(|request| request + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + || request.starts_with( + "GET /api/external/v1/editor/assets/folders/manual-test-assets " + )), + "{requests:?}" + ); + } + } + } + + #[tokio::test] + async fn external_canvas_context_stops_when_account_changes_during_folder_confirmation() { + let temporary = tempfile::tempdir().expect("create folder account switch project"); + let root = temporary.path(); + init_local_game_project_at(root, "folder-switch-local", "目录确认切号测试") + .expect("initialize folder switch project"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind folder switch fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let _session = crate::platform_session::install_test_platform_session( + "folder-user-a", + "folder-token-a", + &base_url, + ); + let (_, _, frozen) = + resolve_canvas_sync_api_credentials(None, None).expect("freeze folder account A"); + let access = + ExternalEditorBindingAccess::for_platform(frozen.as_ref().expect("frozen session")) + .expect("create frozen folder access"); + let principal = + external_editor_binding_principal(&access).expect("folder switch principal"); + let binding = new_external_editor_project_binding( + "folder-switch-local", + &principal, + "folder-switch-project", + "folder-switch-folder", + unix_timestamp(), + ) + .expect("create account A binding"); + write_external_editor_project_binding_at(root, &binding).expect("write account A binding"); + let server = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..2 { + let (mut stream, _) = listener.accept().expect("accept folder switch request"); + let request = read_test_http_request(&mut stream); + assert_eq!( + test_request_header(&request, "authorization"), + "Bearer folder-token-a" + ); + if request.starts_with("GET /api/editor/projects?view=summary ") { + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"projects": [{"projectId": "folder-switch-project"}]}}), + ); + } else { + assert!( + request.starts_with("GET /api/editor/assets/folders/folder-switch-folder ") + ); + install_platform_session("folder-user-b", "folder-token-b", &base_url, 2, 2) + .expect("switch account during folder confirmation"); + write_test_json_response( + &mut stream, + "200 OK", + &serde_json::json!({"data": {"folder": {"folderId": "folder-switch-folder"}}}), + ); + } + requests.push(request); + } + requests + }); + let error = + prepare_external_canvas_generation_context(root, &reqwest::Client::new(), &access) + .await + .expect_err("account switch must stop directory confirmation"); + assert!(error.contains("账号") || error.contains("会话"), "{error}"); + assert_eq!(server.join().expect("join folder switch fixture").len(), 2); + assert_eq!( + read_external_editor_project_binding_at(root, "folder-switch-local", &principal) + .expect("read frozen account binding"), + Some(binding) + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); @@ -9738,8 +10007,7 @@ mod canvas_generation_tests { let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { let mut project_created = BTreeMap::::new(); - let mut folder_created = BTreeMap::::new(); - for _ in 0..10 { + for _ in 0..8 { let (mut stream, _) = listener.accept().expect("accept binding request"); let request = read_test_http_request(&mut stream); let account = match test_request_header(&request, "authorization") { @@ -9762,18 +10030,14 @@ mod canvas_generation_tests { serde_json::json!({"data": {"project": { "projectId": format!("remote-project-{account}") }}}) - } else if request.starts_with("GET /api/editor/assets/library ") { - let folders = if folder_created.get(account).copied().unwrap_or(false) { - vec![serde_json::json!({ - "folderId": format!("remote-folder-{account}"), - "label": "服务端目录标题不参与绑定", - })] - } else { - Vec::new() - }; - serde_json::json!({"data": {"library": {"folders": folders}}}) + } else if request.starts_with(&format!( + "GET /api/editor/assets/folders/remote-folder-{account} " + )) { + serde_json::json!({"data": {"folder": { + "folderId": format!("remote-folder-{account}"), + "label": "服务端目录标题不参与绑定", + }}}) } else if request.starts_with("POST /api/editor/assets/folders ") { - folder_created.insert(account.to_string(), true); serde_json::json!({"data": {"folder": { "folderId": format!("remote-folder-{account}") }}}) @@ -9848,7 +10112,7 @@ mod canvas_generation_tests { context_a.asset_folder_id ); let requests = request_receiver.try_iter().collect::>(); - assert_eq!(requests.len(), 10); + assert_eq!(requests.len(), 8); assert_eq!( requests .iter() @@ -9872,7 +10136,6 @@ mod canvas_generation_tests { project_created: bool, folder_created: bool, project_get_waiters: usize, - folder_get_waiters: usize, } let temporary = tempfile::tempdir().expect("create concurrent binding project"); @@ -9936,25 +10199,9 @@ mod canvas_generation_tests { state.project_created = true; ready.notify_all(); serde_json::json!({"data": {"project": {"projectId": "single-project"}}}) - } else if request.starts_with("GET /api/editor/assets/library ") { - let (state_lock, ready) = &*state; - let mut state = state_lock.lock().expect("lock folder fixture state"); - if !state.folder_created { - state.folder_get_waiters += 1; - ready.notify_all(); - if state.folder_get_waiters < 2 { - let (next, _) = ready - .wait_timeout(state, Duration::from_millis(150)) - .expect("wait for concurrent folder GET"); - state = next; - } - } - let folders = state.folder_created.then(|| { - vec![serde_json::json!({"folderId": "single-folder"})] - }); - serde_json::json!({ - "data": {"library": {"folders": folders.unwrap_or_default()}} - }) + } else if request.starts_with("GET /api/editor/assets/folders/single-folder ") { + assert!(state.0.lock().expect("lock folder fixture state").folder_created); + serde_json::json!({"data": {"folder": {"folderId": "single-folder"}}}) } else if request.starts_with("POST /api/editor/assets/folders ") { let (state_lock, ready) = &*state; let mut state = state_lock.lock().expect("lock folder create state"); @@ -10041,8 +10288,8 @@ mod canvas_generation_tests { let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let (request_sender, request_receiver) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || { - let mut failed_library_once = false; - for _ in 0..5 { + let mut failed_folder_once = false; + for _ in 0..4 { let (mut stream, _) = listener.accept().expect("accept project partial request"); let request = read_test_http_request(&mut stream); request_sender @@ -10062,21 +10309,15 @@ mod canvas_generation_tests { "projectId": "partial-remote-project" }}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") - && !failed_library_once + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") + && !failed_folder_once { - failed_library_once = true; + failed_folder_once = true; write_test_json_response( &mut stream, "500 Internal Server Error", &serde_json::json!({"error": {"code": "fixture-failure"}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_test_json_response( - &mut stream, - "200 OK", - &serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { write_test_json_response( &mut stream, @@ -10097,7 +10338,7 @@ mod canvas_generation_tests { let first_error = prepare_external_canvas_generation_context(root, &client, &access) .await .expect_err("failure after project creation must be surfaced"); - assert!(first_error.contains("读取外部素材库"), "{first_error}"); + assert!(first_error.contains("创建外部素材库目录"), "{first_error}"); let manifest = read_existing_manifest_for_project(root).expect("read partial manifest"); let principal = external_editor_binding_principal(&access).expect("partial principal"); let binding_key = @@ -10159,7 +10400,8 @@ mod canvas_generation_tests { |request| request.starts_with("POST /api/external/v1/editor/assets/folders ") ) .count(), - 1 + 2, + "folder creation retries must reuse the frozen request" ); } @@ -10176,7 +10418,7 @@ mod canvas_generation_tests { let server = std::thread::spawn(move || { let mut project_posts = 0_u8; let mut folder_posts = 0_u8; - for _ in 0..8 { + for _ in 0..6 { let (mut stream, _) = listener.accept().expect("accept frozen binding request"); let request = read_test_http_request(&mut stream); request_sender @@ -10202,12 +10444,6 @@ mod canvas_generation_tests { "projectId": "frozen-remote-project" }}}), ); - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - write_test_json_response( - &mut stream, - "200 OK", - &serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { folder_posts += 1; if folder_posts == 1 { @@ -10257,6 +10493,10 @@ mod canvas_generation_tests { .filter(|request| request.starts_with("POST /api/external/v1/editor/projects ")) .collect::>(); assert_eq!(project_posts.len(), 2); + assert_eq!( + test_request_header(project_posts[0], "idempotency-key"), + test_request_header(project_posts[1], "idempotency-key") + ); assert!(project_posts .iter() .all(|request| request.contains("\"title\":\"首次画布名称\""))); @@ -10268,6 +10508,10 @@ mod canvas_generation_tests { .filter(|request| request.starts_with("POST /api/external/v1/editor/assets/folders ")) .collect::>(); assert_eq!(folder_posts.len(), 2); + assert_eq!( + test_request_header(folder_posts[0], "idempotency-key"), + test_request_header(folder_posts[1], "idempotency-key") + ); assert!(folder_posts .iter() .all(|request| request.contains("\"label\":\"首次画布名称\""))); @@ -10307,24 +10551,27 @@ mod canvas_generation_tests { request_sender .send(request.clone()) .expect("capture folder partial request"); - let response = - if request.starts_with("GET /api/external/v1/editor/projects?view=summary ") { - serde_json::json!({"data": {"projects": []}}) - } else if request.starts_with("POST /api/external/v1/editor/projects ") { - serde_json::json!({"data": {"project": { - "projectId": "folder-partial-project" - }}}) - } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { - serde_json::json!({"data": {"library": {"folders": []}}}) - } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { - fs::create_dir_all(&blocked_binding_path) - .expect("block final binding write after folder creation"); - serde_json::json!({"data": {"folder": { - "folderId": "folder-partial-folder" - }}}) - } else { - panic!("unexpected folder partial request: {request}"); - }; + let response = if request + .starts_with("GET /api/external/v1/editor/projects?view=summary ") + { + serde_json::json!({"data": {"projects": []}}) + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + serde_json::json!({"data": {"project": { + "projectId": "folder-partial-project" + }}}) + } else if request.starts_with( + "GET /api/external/v1/editor/assets/folders/folder-partial-folder ", + ) { + serde_json::json!({"data": {"folder": {"folderId": "folder-partial-folder"}}}) + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + fs::create_dir_all(&blocked_binding_path) + .expect("block final binding write after folder creation"); + serde_json::json!({"data": {"folder": { + "folderId": "folder-partial-folder" + }}}) + } else { + panic!("unexpected folder partial request: {request}"); + }; write_test_json_response(&mut stream, "200 OK", &response); } }); @@ -10333,7 +10580,6 @@ mod canvas_generation_tests { .await .expect_err("final binding write must fail"); assert!(first_error.contains("必须是普通文件"), "{first_error}"); - server.join().expect("join folder partial fixture"); let partial = read_external_canvas_binding_partial(root, &binding_key, &manifest.project_id) .expect("read folder partial") @@ -10350,10 +10596,20 @@ mod canvas_generation_tests { let recovered = prepare_external_canvas_generation_context(root, &client, &access) .await - .expect("recover folder partial without remote requests"); + .expect("recover folder partial by confirming its directory"); + server.join().expect("join folder partial fixture"); assert_eq!(recovered.project_id, "folder-partial-project"); assert_eq!(recovered.asset_folder_id, "folder-partial-folder"); let requests = request_receiver.try_iter().collect::>(); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with( + "GET /api/external/v1/editor/assets/folders/folder-partial-folder " + )) + .count(), + 1 + ); assert_eq!( requests .iter() @@ -15730,15 +15986,15 @@ mod canvas_generation_tests { ); return; } - if request.starts_with("GET /api/external/v1/editor/assets/library ") { + if request.starts_with("GET /api/external/v1/editor/assets/folders/manual-test-assets ") { write_test_json_response( stream, "200 OK", &serde_json::json!({ - "data": {"library": {"folders": [{ + "data": {"folder": { "folderId": "manual-test-assets", "label": "场景生成素材", - }]}} + }} }), ); return; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 503593ce4..9e7c8518e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -629,7 +629,7 @@ const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// information whenever a safe error line mentions a credential field. /// /// 逐行脱敏,但**保留每个 chunk 末尾的换行**:本函数会被流式增量逐段调用 -/// (`thread_delta_text` → 前端把各段拼成一条消息再交给 Markdown 渲染)。用 +/// (脱敏后的各段由前端拼成一条消息再交给 Markdown 渲染)。用 /// `lines()` + `join("\n")` 会把「以换行结尾的段」的末尾换行吃掉,拼接后段落、列表项和表格行 /// 会并进同一行,整条消息的 Markdown 结构(尤其表格)就作废了。 pub(crate) fn sanitize_error_context(value: &str) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index cd700bfc9..ec68232d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -19,10 +19,10 @@ use futures::FutureExt; use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_codex_user_item_to_prompt, direct_now_ms, record_direct_codex_failure, - redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, - thread_id_for_project, DirectCodexFailureStage, DirectGameCreatorTurnUpdateEmitter, - DirectTurnError, DirectTurnTerminal, DispatchedTurn, + direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure, + run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, + DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, TurnCompletion, TurnError, + TurnErrorClassified, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -54,11 +54,11 @@ impl TurnReservation { /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 /// /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + pub(crate) fn finish_if_unfinished(&self, terminal: TurnCompletion) -> bool { complete_turn_if_reserved( &self.thread_id, &self.token, - terminal.event(direct_now_ms(), self.user_item_id.as_deref()), + terminal.event(now_ms(), self.user_item_id.as_deref()), ) } @@ -75,7 +75,7 @@ impl TurnReservation { })) .expect("canonical user item"), None, - direct_now_ms(), + now_ms(), ); super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); @@ -87,7 +87,7 @@ impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -96,16 +96,16 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - let failure = DirectTurnError::TurnFailed { - stage: DirectCodexFailureStage::CodeGeneration, - // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, - // 整行被替换成 ``,离线就只剩一个 - // 说不出原因的 HostDropped。 - detail: format!( + // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, + // 整行被替换成 ``,离线就只剩一个 + // 说不出原因的 HostDropped。 + let failure = TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} tt={}", self.thread_id, self.token ), - }; + ); let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None); app_log!( "agent.direct_turn.host_dropped threadId={} tt={} userItemId={} panicking={}", @@ -222,14 +222,17 @@ async fn run_dispatched_direct_turn( if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 // 消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - &root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + // 原文不在这里脱敏:`classify` 在投影成失败载荷时统一脱敏 + 截断(错误文案的脱敏保留)。 + let failure = TurnError::environment_not_ready(format!("写入本项目对话历史失败:{error}")); + // 这条错误是本地构造的**真失败**:classify 只可能给 ShouldStop。 + match failure.classify(&root) { + TurnErrorClassified::ShouldStop(payload) => { + reservation.finish_if_unfinished(TurnCompletion::failed(payload)); + } + TurnErrorClassified::ShouldContinue { detail } => { + unreachable!("environment_not_ready 只可能是回合失败,不该分类成控制流:{detail}") + } + } return; } // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 @@ -257,16 +260,17 @@ async fn run_dispatched_direct_turn( { Ok(result) => result, Err(payload) => { - let failure = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let failure = TurnError::turn_failed( + FailureStage::CodeGeneration, format!( "DirectProject 宿主任务 panic:{}", direct_turn_panic_detail(payload.as_ref()) ), ); - let stage = failure.turn_failure_stage(); - let detail = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str())); - Err(DirectTurnError::TurnFailed { stage, detail }) + // 审计照写,载荷原样透传:panic 本身已经是一条 `SuperErrorFromStringPlusStage`, + // 不需要再包一层。 + let _ = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str())); + Err(failure) } }; match outcome { @@ -277,12 +281,24 @@ async fn run_dispatched_direct_turn( // 线程仍被这条放行占用,不补写终态的话,占用对象 Drop 时的兜底会把一轮已经拿到回复的 // 回合收成 `HostDropped`(真实案例:2026-10-03 `claude-parse-done chars=176` 之后立刻 // `host_dropped panicking=false`)。codex 路径已写过终态,这里是空操作。 - reservation.finish_if_unfinished(DirectTurnTerminal::completed()); + reservation.finish_if_unfinished(TurnCompletion::Completed); } Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + // 按 `classify` 显式分流,不留静默分支:控制流(返修 / 复核要求继续)本应被 + // `direct_runtime` 的返修循环消化成下一轮;两条来源(交付复核、app-server 封口复核) + // 都在循环里接住了。漏到这里就是上游缺陷——直接炸出来,不再静默吞掉。 + match error.classify(&root) { + TurnErrorClassified::ShouldStop(payload) => { + reservation.finish_if_unfinished(TurnCompletion::failed(payload)); + } + TurnErrorClassified::ShouldContinue { detail } => { + unreachable!( + "控制流错误到达回合失败收口(应在 direct_runtime 的返修循环内消化):{detail}" + ) + } + } } } } @@ -291,8 +307,8 @@ async fn run_dispatched_direct_turn( mod tests { use super::*; use crate::agent::{ - consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, - DirectTurnFailure, DirectTurnFailureKind, PendingTurn, ThreadEvent, + consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, Deadline, + PendingTurn, ThreadEvent, TimedOut, TurnCompletedStatus, TurnFailure, }; use uuid::Uuid; @@ -326,7 +342,7 @@ mod tests { })) .expect("canonical user item"), None, - direct_now_ms(), + now_ms(), ) } @@ -355,9 +371,9 @@ mod tests { }, ); - assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!foreign.finish_if_unfinished(TurnCompletion::host_dropped())); assert!(thread_turn_is_active(&thread)); - assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(owner.finish_if_unfinished(TurnCompletion::host_dropped())); assert!(!thread_turn_is_active(&thread)); // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 drop(foreign); @@ -375,17 +391,16 @@ mod tests { // 深层收口:真正跑完这一轮的代码算出来的终态。 let deep = ThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), + TurnFailure::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + }), 2_000, ) .with_user_item_id(Some("direct-codex:turn-1:user")); crate::agent::complete_turn(&thread, deep); assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + !reservation.finish_if_unfinished(TurnCompletion::host_dropped()), "深层已收口时兜底不许再写" ); drop(reservation); @@ -396,8 +411,10 @@ mod tests { match completed[0] { ThreadEvent::TurnCompleted { failure, .. } => { assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) + failure, + &Some(TurnFailure::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) ); } other => panic!("expected a terminal, got {other:?}"), @@ -426,7 +443,7 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) ), "{events:?}" ); @@ -489,7 +506,7 @@ mod tests { assert!( events.iter().any(|event| matches!( event, - ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { status, .. } if *status == TurnCompletedStatus::Aborted )), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 87b19c252..da0fdff89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -6,10 +6,12 @@ pub(crate) mod dispatch; pub(crate) mod queue; +pub(crate) mod turn_completion; pub(crate) mod wire; pub(crate) use dispatch::*; pub(crate) use queue::*; +pub(crate) use turn_completion::*; pub(crate) use wire::*; use std::collections::{HashMap, HashSet}; @@ -17,7 +19,7 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, direct_now_ms, queue_has_room, ConsumeResult, + direct_codex_user_item_id_for_client_turn_id, now_ms, queue_has_room, ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, QueueRemovalReason, SubscriptionBootstrap, ThreadEvent, }; @@ -377,7 +379,8 @@ impl ThreadManager { // `turn.started` 的口径同一份(`PendingTurn::user_item_id`)。 self.append( thread_id, - ThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + TurnCompletion::Aborted.event( + now_ms, direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), ), ); @@ -854,7 +857,7 @@ pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> Queu global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove_pending_turn(thread_id, client_turn_id, direct_now_ms()) + .remove_pending_turn(thread_id, client_turn_id, now_ms()) }; if outcome == QueueRemovalOutcome::Removed { notify_subscribers(thread_id); @@ -873,7 +876,7 @@ pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .claim_pending_turn(thread_id, direct_now_ms()) + .claim_pending_turn(thread_id, now_ms()) }; if claimed.is_some() { notify_subscribers(thread_id); @@ -950,12 +953,7 @@ pub(crate) fn release_stale_direct_turn( global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .release_stale_turn( - thread_id, - expected_client_turn_id, - min_age_ms, - direct_now_ms(), - ) + .release_stale_turn(thread_id, expected_client_turn_id, min_age_ms, now_ms()) }; if matches!(outcome, StaleTurnRelease::Released(_)) { notify_subscribers(thread_id); @@ -1243,13 +1241,13 @@ mod tests { let mut manager = ThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - ThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), + ThreadEvent::turn_completed(TurnOutcome::Completed, FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), [ThreadEvent::TurnCompleted { status, at, .. }] - if status == "completed" && *at == Some(FIXED_AT_MS) + if *status == TurnCompletedStatus::Completed && *at == Some(FIXED_AT_MS) )); } @@ -1261,22 +1259,18 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::HostDropped, - "回合宿主任务提前结束", - ), - FIXED_AT_MS, - ), + ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), [ThreadEvent::TurnCompleted { status, failure, at, .. }] - if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind - == crate::agent::DirectTurnFailureKind::HostDropped) + if *status == TurnCompletedStatus::Failed + && failure.as_ref().is_some_and(|failure| matches!( + failure, + crate::agent::TurnFailure::HostDropped + )) && *at == Some(FIXED_AT_MS) )); } @@ -1356,7 +1350,7 @@ mod tests { ); manager.append( "thread-1", - ThreadEvent::turn_completed("completed".to_string(), 3_000) + ThreadEvent::turn_completed(TurnOutcome::Completed, 3_000) .with_user_item_id(Some("direct-codex:turn-1:user")), ); let second = manager.subscribe("thread-1"); @@ -1445,7 +1439,7 @@ mod tests { manager.complete_turn( thread_id, - ThreadEvent::turn_completed("completed".to_string(), 5_000), + ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000), ); assert!(snapshot_of(&manager, thread_id).is_none()); } @@ -1739,7 +1733,7 @@ mod tests { assert!( matches!( events[0], - ThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { ref status, .. } if *status == TurnCompletedStatus::Aborted ), "{events:?}" ); @@ -1838,7 +1832,7 @@ mod tests { complete_turn( &thread_id, - ThreadEvent::turn_completed("completed".to_string(), 5_000), + ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000), ); assert_eq!( crate::agent::direct_active_turns_event_test_count(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs index 12fc6879c..3c9745b32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -9,9 +9,7 @@ //! 这条判据的成员折叠(`ThreadManager::pending_turns`)。 //! 它不碰锁、不碰 Tauri、不写盘、不重算 prompt:入队检查在命令侧,放行顺序在 Thread Manager。 -use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, ThreadEvent, -}; +use crate::agent::{direct_codex_user_item_id_for_client_turn_id, ThreadEvent, UserItem}; /// 一个项目最多能同时排队的待发消息条数。 /// @@ -30,7 +28,7 @@ pub(crate) struct PendingTurn { /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 pub(crate) client_turn_id: String, /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 - pub(crate) user_item: DirectCodexUserItem, + pub(crate) user_item: UserItem, pub(crate) creation_type: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, @@ -39,7 +37,7 @@ pub(crate) struct PendingTurn { impl PendingTurn { pub(crate) fn new( client_turn_id: String, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, at: u64, ) -> Self { @@ -116,7 +114,7 @@ mod tests { use super::*; use serde_json::json; - fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + fn user_item(text: &str, id: &str) -> UserItem { serde_json::from_value(json!({ "type": "message", "role": "user", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs new file mode 100644 index 000000000..e97280e1b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -0,0 +1,317 @@ +//! 一轮的**终态**:宿主侧的内部判别联合,不下发、不导出。 +//! +//! 属于线上协议的只有失败**载荷** [`TurnFailure`](`turn.completed.failure` 的形状,留在 +//! [`super::wire`]);"这一轮是怎么收场的"是宿主自己的概念,所以放在这里、不进 wire,也绝不 +//! 加 `Serialize` / `TS`——一旦能序列化就会被误当成线上形状。 +//! +//! 这里只有两件事,别再往里加第三件: +//! 1. [`turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 2. [`TurnCompletion::event`]:把终态投影成 `turn.completed` 事件(`status` 由变体反推)。 +//! +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 [`super::dispatch`] 的放行占用对象里: +//! 这里只负责"什么算失败、原因怎么写"。 +//! +//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何 +//! 地方再解析它。 + +use std::path::Path; + +use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; + +use super::wire::TurnFailure; + +/// 一轮的收场:正常收场只有前三档,失败必须带载荷。 +/// +/// **有载荷就一定是失败,没载荷才看收尾阶段推出来的 `status`**——这条反推关系是这个类型存在的 +/// 理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, +/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", +/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TurnCompletion { + Completed, + Interrupted, + /// 执行进程已退出 / 这一轮从没进执行器:不会再有人替它发终态的兜底收场。 + Aborted, + Failed(TurnFailure), +} + +impl TurnCompletion { + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { + let event = match self { + Self::Failed(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), + Self::Completed => ThreadEvent::turn_completed(TurnOutcome::Completed, completed_at), + Self::Interrupted => { + ThreadEvent::turn_completed(TurnOutcome::Interrupted, completed_at) + } + Self::Aborted => ThreadEvent::turn_completed(TurnOutcome::Aborted, completed_at), + }; + event.with_user_item_id(user_item_id) + } + + /// 一次失败终态:载荷已由 [`TurnError::classify`] 投影并脱敏。 + pub(crate) fn failed(failure: TurnFailure) -> Self { + Self::Failed(failure) + } + + /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 + /// + /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + pub(crate) fn host_dropped() -> Self { + Self::Failed(TurnFailure::HostDropped) + } +} + +/// 拿这一轮的**事实**判定终态。判据按优先级: +/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), +/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; +/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: +/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`TurnError`] 再进来; +/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 +/// 阶段的账本读不出来时只有它可用。 +/// +/// 错误由 [`TurnError::classify`] 分成两层:`ShouldStop` 的载荷直接成失败终态;`ShouldContinue` +/// (返修 / 复核要求继续)说明这一轮还没结束,在产生 / 消费它的那一层就被消化, +/// `codex_app_server` 的终态投影也把它过滤成了 `None`。它不该、也不能进入终态判定: +/// 漏进来就是上游缺陷,直接 `unreachable!`,不落任何终态。 +/// +/// 载荷在这一个出口从 typed 错误投影([`TurnError::classify`]):脱敏与截断也在那一处完成, +/// Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。 +pub(crate) fn turn_terminal( + session_status: &str, + collect_outcome: Result<&str, TurnError>, + host_failure: Option<&TurnError>, + history_root: &Path, +) -> TurnCompletion { + let error = match (host_failure, collect_outcome) { + (Some(failure), _) => Some(failure.clone()), + // `collect_outcome` 按值匹配,`Err(error)` 已经把 `TurnError` 移出来,不必再 clone。 + (None, Err(error)) => Some(error), + // 账本读不出来时(`session_status == "failed"`)没有 typed 原因可用:报告文本就是这一轮 + // 唯一的收口依据,按未分类失败发出去,不能让界面停在"已结束、没原因"。 + (None, Ok(report)) if session_status == "failed" => { + Some(TurnError::Unclassified(Unclassified { + detail: report.to_string(), + })) + } + (None, Ok(_)) => None, + }; + if let Some(error) = error { + match error.classify(history_root) { + TurnErrorClassified::ShouldStop(payload) => return TurnCompletion::Failed(payload), + // 控制流:这一轮还没结束,不该走到终态判定。上游的返修循环与 `direct_turn_terminal_write` + // 都已把它挡在外面;漏进来就是缺陷,直接炸出来,不落任何终态。 + TurnErrorClassified::ShouldContinue { detail } => { + unreachable!("控制流错误不应进入终态判定:{detail}") + } + } + } + session_completion(session_status).unwrap_or_else(|| { + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 + let error = TurnError::Unclassified(Unclassified { + detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), + }); + match error.classify(history_root) { + TurnErrorClassified::ShouldStop(payload) => TurnCompletion::Failed(payload), + // `Unclassified` 恒为真失败;这一臂写全只是把"分类层不允许静默"补齐。 + TurnErrorClassified::ShouldContinue { .. } => { + unreachable!("Unclassified 只可能是回合失败,不该分类成控制流") + } + } + }) +} + +/// 收尾阶段账本给出的**非失败** `status` → 终态;认不出的值返回 `None` 由 [`turn_terminal`] 按 +/// 失败兜底。 +/// +/// 这就是原 `SessionOutcome` 的全部内容——它只是"没有 `Failed` 的 [`TurnCompletion`]",并进来 +/// 少一个同义类型。 +fn session_completion(status: &str) -> Option { + match status { + "completed" => Some(TurnCompletion::Completed), + "interrupted" => Some(TurnCompletion::Interrupted), + "aborted" => Some(TurnCompletion::Aborted), + // `failed` 不走这里:它要么带 typed 错误、要么按报告文本兜底。 + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + ModelCallKind, ThreadEvent, TransportClosed, TurnCompletedStatus, TurnError, + TurnInterrupted, Unclassified, + }; + use platform_llm::LlmError; + + fn history_root() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/direct-turn-completion-test") + } + + fn model_call(payload: &TurnFailure) -> (&ModelCallKind, &str) { + match payload { + TurnFailure::ModelCallFailed(payload) => (&payload.kind, &payload.detail), + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 正常收场:不带载荷,变体就是收尾阶段推出来的那个。 + #[test] + fn non_failure_terminals_keep_the_session_status() { + assert_eq!( + turn_terminal("completed", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Completed + ); + assert_eq!( + turn_terminal("interrupted", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Interrupted + ); + assert_eq!( + turn_terminal("aborted", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Aborted + ); + } + + /// 认不出的 `status` 不冒充正常收场:按未分类失败发出去,并把原文留在原因里。 + #[test] + fn unknown_session_status_fails_closed() { + let completion = turn_terminal("something-new", Ok("报告"), None, &history_root()); + match completion { + TurnCompletion::Failed(TurnFailure::Unclassified(payload)) => { + assert!(payload.detail.contains("something-new")); + } + other => panic!("unknown status must fail closed, got {other:?}"), + } + } + + /// 拿得到错误:分类与原因都取自错误。 + #[test] + fn collect_error_becomes_a_failure_terminal() { + let error = TurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), + )); + let completion = turn_terminal("completed", Err(error), None, &history_root()); + let TurnCompletion::Failed(failure) = completion else { + panic!("transport error must fail the turn"); + }; + let (kind, detail) = model_call(&failure); + assert_eq!(*kind, ModelCallKind::TransportBroken); + assert!(detail.contains("project.jsonl")); + } + + /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, + /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 + #[test] + fn unreadable_session_ledger_still_reports_a_payload() { + assert_eq!( + turn_terminal("failed", Ok("报告"), None, &history_root()), + TurnCompletion::Failed(TurnFailure::Unclassified(Unclassified { + detail: "报告".into() + })) + ); + } + + /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 + #[test] + fn host_recorded_failure_outranks_every_other_source() { + let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ +stderrClass=nonempty;stderrBytes=1000"; + let host_failure = TurnError::TransportClosed(TransportClosed { + diagnostic: diagnostic.to_string(), + }); + let completion = turn_terminal( + "interrupted", + Ok("执行连接已结束,正在核对自有子进程与在途操作。"), + Some(&host_failure), + &history_root(), + ); + let TurnCompletion::Failed(failure) = completion else { + panic!("host fact must fail the turn"); + }; + match &failure { + TurnFailure::TransportClosed(payload) => { + assert!(payload.diagnostic.contains("SIGKILL")); + assert!(!payload.diagnostic.contains("正在核对自有子进程")); + } + other => panic!("expected a transport-closed payload, got {other:?}"), + } + + // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 + let error = + TurnError::from_model_call(&LlmError::Transport("DirectProject 收尾历史失败".into())); + let host_failure = TurnError::TurnInterrupted(TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }); + let completion = turn_terminal( + "interrupted", + Err(error), + Some(&host_failure), + &history_root(), + ); + let TurnCompletion::Failed(failure) = completion else { + panic!("host fact must fail the turn"); + }; + match &failure { + TurnFailure::TurnInterrupted(payload) => { + assert!(payload.detail.contains("本轮模型执行被中断")) + } + other => panic!("expected a turn-interrupted payload, got {other:?}"), + } + } + + /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + #[test] + fn terminal_event_carries_the_payload_and_the_opening_identity() { + let error = TurnError::from_model_call(&LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }); + let failing = turn_terminal("interrupted", Err(error), None, &history_root()); + let event = failing.event(2_000, Some("direct-codex:turn-1:user")); + match event.failure() { + Some(TurnFailure::ModelCallFailed(payload)) => assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 502, + native: None + } + ), + other => panic!("expected a model call failure payload, got {other:?}"), + } + assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!(event.at(), Some(2_000)); + + let quiet = turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); + let event = quiet.event(3_000, None); + assert!(event.failure().is_none()); + assert!(matches!( + event, + ThreadEvent::TurnCompleted { ref status, .. } if *status == TurnCompletedStatus::Completed + )); + } + + /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + #[test] + fn host_dropped_terminal_only_carries_the_classification() { + assert_eq!( + TurnCompletion::host_dropped(), + TurnCompletion::Failed(TurnFailure::HostDropped) + ); + } + + /// 成功终态:没有失败载荷,`status` 就是 `completed`。 + /// + /// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后, + /// 线程仍被放行占用,必须由放行侧补写这条终态。 + #[test] + fn completed_terminal_carries_no_failure_payload() { + let event = TurnCompletion::Completed.event(1_700_000_000_000, Some("item-1")); + assert!(event.failure().is_none()); + assert!(matches!( + event, + ThreadEvent::TurnCompleted { ref status, .. } if *status == TurnCompletedStatus::Completed + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs deleted file mode 100644 index 9bab8b252..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs +++ /dev/null @@ -1,1621 +0,0 @@ -//! DirectProject 聊天事件的线上模型与投影。 -//! -//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, -//! 与 Rust 定义同源:加一个字段不会只改一边。 -//! -//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 -//! 合并规则全部属于前端投影,这里一概不出现。 -//! -//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server -//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, -//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 -//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 - -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::agent::DirectCodexUserItem; -use crate::agent::DirectTurnFailureKind; -use crate::redact_absolute_path_tokens; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// 宿主观测时刻:Unix 毫秒。 -pub(crate) fn direct_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64 -} - -/// 正文(消息 / 思考)上限。 -const THREAD_TEXT_MAX_CHARS: usize = 8_000; -/// 工具明细(命令 / 参数 / 输出)上限。 -const THREAD_DETAIL_MAX_CHARS: usize = 4_000; -/// 单条变更路径上限。 -const THREAD_PATH_MAX_CHARS: usize = 300; - -/// 一条文件变更。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct ThreadFileChange { - pub(crate) path: String, - /// `add` | `update` | `delete` - pub(crate) kind: String, -} - -/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 -/// -/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 -/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 -/// -/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, -/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadItem { - #[serde(rename = "message")] - Message { - /// 归一身份:全链路只有这一个 id。 - item_id: String, - /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 - role: String, - text: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "reasoning")] - Reasoning { - item_id: String, - text: String, - #[ts(as = "f64")] - at: u64, - }, - /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 - /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 - #[serde(rename = "function_call")] - FunctionCall { - item_id: String, - name: String, - arguments: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "function_call_output")] - FunctionCallOutput { - item_id: String, - output: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "commandExecution")] - CommandExecution { - item_id: String, - command: String, - #[serde(default)] - output: Option, - /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … - #[serde(default)] - status: Option, - #[serde(default)] - #[ts(as = "Option")] - exit_code: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "fileChange")] - FileChange { - item_id: String, - changes: Vec, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "mcpToolCall")] - McpToolCall { - item_id: String, - tool: String, - arguments: String, - #[serde(default)] - output: Option, - #[serde(default)] - status: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "webSearch")] - WebSearch { - item_id: String, - #[serde(default)] - query: Option, - #[serde(default)] - output: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "contextCompaction")] - ContextCompaction { - item_id: String, - #[ts(as = "f64")] - at: u64, - }, - /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 - #[serde(rename = "other")] - Other { - item_id: String, - raw_type: String, - #[ts(as = "f64")] - at: u64, - }, -} - -impl ThreadItem { - /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 - pub(crate) fn item_id(&self) -> &str { - match self { - Self::Message { item_id, .. } - | Self::Reasoning { item_id, .. } - | Self::FunctionCall { item_id, .. } - | Self::FunctionCallOutput { item_id, .. } - | Self::CommandExecution { item_id, .. } - | Self::FileChange { item_id, .. } - | Self::McpToolCall { item_id, .. } - | Self::WebSearch { item_id, .. } - | Self::ContextCompaction { item_id, .. } - | Self::Other { item_id, .. } => item_id, - } - } - - /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; - /// 那两类边界用事件级 `at`。 - pub(crate) fn at(&self) -> u64 { - match self { - Self::Message { at, .. } - | Self::Reasoning { at, .. } - | Self::FunctionCall { at, .. } - | Self::FunctionCallOutput { at, .. } - | Self::CommandExecution { at, .. } - | Self::FileChange { at, .. } - | Self::McpToolCall { at, .. } - | Self::WebSearch { at, .. } - | Self::ContextCompaction { at, .. } - | Self::Other { at, .. } => *at, - } - } -} - -/// 增量正文属于哪类条目。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadDeltaKind { - /// assistant 正文。 - Message, - /// 思考正文。 - Reasoning, -} - -/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadRequestKind { - #[serde(rename = "approval.requested")] - ApprovalRequested, - #[serde(rename = "ask.requested")] - AskRequested, - #[serde(rename = "request.resolved")] - RequestResolved, -} - -impl ThreadRequestKind { - /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 - pub(crate) fn is_request(&self) -> bool { - matches!(self, Self::ApprovalRequested | Self::AskRequested) - } - - pub(crate) fn is_resolution(&self) -> bool { - !self.is_request() - } -} - -/// 一条待发消息离开队列的原因。 -/// -/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 -/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum QueueRemovalReason { - /// 用户取消了这条待发消息。 - Cancelled, - /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 - Dispatched, -} - -/// 取消一条待发消息的结果。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum QueueRemovalOutcome { - /// 已从队列移除。 - Removed, - /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 - AlreadyDispatched, - /// 队列里没有这个身份,也没有在跑的一轮是它。 - NotFound, -} - -/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 -/// -/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 -/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnFailure { - /// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 - /// [`DirectTurnError::wire_kind`]。 - pub(crate) kind: DirectTurnFailureKind, - /// 脱敏 + 截断后的失败原因。 - pub(crate) message: String, -} - -impl DirectTurnFailure { - pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into) -> Self { - Self { - kind, - message: message.into(), - } - } -} - -/// Thread Manager 下发的运行态事件。 -/// -/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: -/// 游标是 Thread Manager 的内部事实,不下发。 -/// -/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 -/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 -/// -/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) -/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 -/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 -/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; -/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 -/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 -/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, -/// 不能在前端收到或重放时重新取当前时间。 -/// -/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical -/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; -/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 -/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 -/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 -/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadEvent { - #[serde(rename = "turn.started")] - TurnStarted { - /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 - /// `turn/start` 的时刻)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - user_item_id: Option, - }, - #[serde(rename = "turn.completed")] - TurnCompleted { - /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, - /// 此时必须带 `failure` 载荷。 - status: String, - /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - failure: Option, - /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - user_item_id: Option, - }, - #[serde(rename = "item.started")] - ItemStarted { - item: ThreadItem, - /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, - #[serde(rename = "item.completed")] - ItemCompleted { - item: ThreadItem, - /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, - #[serde(rename = "item.delta")] - ItemDelta { - item_id: String, - kind: ThreadDeltaKind, - delta: String, - }, - #[serde(rename = "request")] - Request { - kind: ThreadRequestKind, - #[serde(default)] - request_id: Option, - }, - /// 待发消息入队:数组顺序就是队首到队尾的顺序。 - /// - /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 - /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 - /// - /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 - /// 从这条条目重投影。 - #[serde(rename = "queue.enqueued")] - QueueEnqueued { - /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 - client_turn_id: String, - /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 - user_item: DirectCodexUserItem, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - creation_type: Option, - /// 入队那一刻的宿主毫秒钟。 - #[ts(as = "f64")] - at: u64, - }, - /// 待发消息离开队列:`reason` 是取消还是放行。 - #[serde(rename = "queue.removed")] - QueueRemoved { - client_turn_id: String, - reason: QueueRemovalReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, -} - -impl ThreadEvent { - pub(crate) fn turn_started(at: u64) -> Self { - Self::TurnStarted { - at: Some(at), - user_item_id: None, - } - } - - pub(crate) fn turn_completed(status: String, at: u64) -> Self { - Self::TurnCompleted { - status, - failure: None, - at: Some(at), - user_item_id: None, - } - } - - /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 - pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self { - Self::TurnCompleted { - status: "failed".to_string(), - failure: Some(failure), - at: Some(at), - user_item_id: None, - } - } - - /// 失败载荷:只有失败终态有。 - pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> { - match self { - Self::TurnCompleted { failure, .. } => failure.as_ref(), - _ => None, - } - } - - /// 附上本轮开口用户条目的 canonical itemId。 - /// - /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 - /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 - pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { - let user_item_id = user_item_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match self { - Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, - Self::TurnCompleted { - status, - failure, - at, - .. - } => Self::TurnCompleted { - status, - failure, - at, - user_item_id, - }, - other => other, - } - } - - /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 - /// - /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 - #[cfg(test)] - pub(crate) fn user_item_id(&self) -> Option<&str> { - match self { - Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { - user_item_id.as_deref() - } - _ => None, - } - } - - pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { - Self::ItemStarted { item, at: Some(at) } - } - - pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { - Self::ItemCompleted { item, at: Some(at) } - } - - pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { - Self::ItemDelta { - item_id, - kind, - delta, - } - } - - pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { - Self::Request { kind, request_id } - } - - /// 待发消息入队事件。 - pub(crate) fn queue_enqueued( - client_turn_id: String, - user_item: DirectCodexUserItem, - creation_type: Option, - at: u64, - ) -> Self { - Self::QueueEnqueued { - client_turn_id, - user_item, - creation_type, - at, - } - } - - /// 待发消息离开队列事件。 - pub(crate) fn queue_removed( - client_turn_id: String, - reason: QueueRemovalReason, - at: u64, - ) -> Self { - Self::QueueRemoved { - client_turn_id, - reason, - at: Some(at), - } - } - - /// 这条事件属于哪条待发消息:只有队列事件有。 - pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { - match self { - Self::QueueEnqueued { client_turn_id, .. } - | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), - _ => None, - } - } - - /// 待发消息离开队列的原因:只有 `queue.removed` 有。 - pub(crate) fn queue_removal_reason(&self) -> Option { - match self { - Self::QueueRemoved { reason, .. } => Some(*reason), - _ => None, - } - } - - /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 - /// - /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 - #[cfg(test)] - pub(crate) fn at(&self) -> Option { - match self { - Self::TurnStarted { at, .. } - | Self::TurnCompleted { at, .. } - | Self::ItemStarted { at, .. } - | Self::ItemCompleted { at, .. } => *at, - Self::QueueEnqueued { at, .. } => Some(*at), - Self::QueueRemoved { at, .. } => *at, - Self::ItemDelta { .. } | Self::Request { .. } => None, - } - } - - /// 事件关联的条目身份:只有 item 事件有。 - pub(crate) fn item_id(&self) -> Option<&str> { - match self { - Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { - Some(item.item_id()) - } - _ => None, - } - } - - pub(crate) fn request_id(&self) -> Option<&str> { - match self { - Self::Request { request_id, .. } => request_id.as_deref(), - _ => None, - } - } - - pub(crate) fn request_kind(&self) -> Option { - match self { - Self::Request { kind, .. } => Some(*kind), - _ => None, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct SubscriptionBootstrap { - pub(crate) subscription_id: String, - /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 - #[serde(default)] - pub(crate) last_completed_item_id: Option, - /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct ConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct HistorySlice { - /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 - pub(crate) items: Vec, - pub(crate) has_more: bool, - /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 - #[serde(default)] - pub(crate) first_item_id: Option, -} - -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -fn detail_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) -} - -/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 -fn value_text(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - other => serde_json::to_string_pretty(other).ok(), - } -} - -fn item_text(root: &Path, item: &Value) -> Option { - let raw = item - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None - }) - .filter(|text| !text.trim().is_empty())?; - Some(bounded( - &sanitize_detail_text(root, &raw), - THREAD_TEXT_MAX_CHARS, - )) -} - -/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 -pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) -} - -fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default(); - if from_metadata > 0 { - return from_metadata; - } - for key in ["startedAtMs", "completedAtMs"] { - let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); - if value > 0 { - return value; - } - } - observed_at_ms -} - -/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 -fn json_ms(container: &Value, key: &str) -> Option { - container - .get(key) - .and_then(Value::as_u64) - .filter(|value| *value > 0) -} - -/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 -/// -/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, -/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 -/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 -/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 -pub(crate) fn thread_item_event_at_ms( - params: &Value, - item: &Value, - completed: bool, - observed_at_ms: u64, -) -> u64 { - let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); - if !completed { - return started_ms().unwrap_or(observed_at_ms); - } - if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { - return at; - } - let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); - match (started_ms(), duration_ms) { - (Some(started), Some(duration)) => started.saturating_add(duration), - _ => observed_at_ms, - } -} - -/// `turn.completed` 的事件级阶段时间(毫秒)。 -/// -/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 -/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 -/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 -/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 -/// 整轮吞掉。因此这里不采用任何秒字段: -/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; -/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 -/// -/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` -/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 -pub(crate) fn thread_turn_completed_at_ms( - turn: &Value, - high_precision_started_at_ms: Option, - observed_at_ms: u64, -) -> u64 { - match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { - (Some(started), Some(duration)) => started.saturating_add(duration), - _ => observed_at_ms, - } -} - -/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 -pub(crate) fn thread_item_identity(item: &Value) -> Option { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - call_id.or(id) -} - -fn item_changes(root: &Path, item: &Value) -> Vec { - item.get("changes") - .and_then(Value::as_array) - .map(|changes| { - changes - .iter() - .filter_map(|change| { - let path = change - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty())?; - Some(ThreadFileChange { - path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -fn field_text(root: &Path, item: &Value, key: &str) -> Option { - item.get(key) - .and_then(value_text) - .map(|value| detail_text(root, &value)) -} - -/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 -/// -/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn thread_item_from_value( - root: &Path, - item: &Value, - observed_at_ms: u64, -) -> Option { - if !item.is_object() { - return None; - } - let item_id = thread_item_identity(item)?; - let item_type = item - .get("type") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let at = item_at_ms(item, observed_at_ms); - let text = item_text(root, item); - let role = item - .get("role") - .and_then(Value::as_str) - .map(str::trim) - .filter(|role| !role.is_empty()) - .map(str::to_string); - - Some(match item_type { - "message" | "agentMessage" | "userMessage" => ThreadItem::Message { - item_id, - role: role.unwrap_or_else(|| { - if item_type == "userMessage" { - "user".to_string() - } else { - "assistant".to_string() - } - }), - text: text?, - at, - }, - "reasoning" => ThreadItem::Reasoning { - item_id, - text: text?, - at, - }, - "function_call" => ThreadItem::FunctionCall { - item_id, - name: item - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), - at, - }, - "function_call_output" => ThreadItem::FunctionCallOutput { - item_id, - output: field_text(root, item, "output").unwrap_or_default(), - at, - }, - "commandExecution" => ThreadItem::CommandExecution { - item_id, - command: item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| detail_text(root, command)) - .unwrap_or_default(), - output: ["aggregatedOutput", "output", "error"] - .iter() - .find_map(|key| field_text(root, item, key)), - status: item - .get("status") - .and_then(Value::as_str) - .map(str::to_string), - exit_code: item.get("exitCode").and_then(Value::as_i64), - at, - }, - "fileChange" => ThreadItem::FileChange { - item_id, - changes: item_changes(root, item), - at, - }, - "mcpToolCall" => ThreadItem::McpToolCall { - item_id, - tool: item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)) - .unwrap_or_default(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), - output: ["result", "error"] - .iter() - .find_map(|key| field_text(root, item, key)), - status: item - .get("status") - .and_then(Value::as_str) - .map(str::to_string), - at, - }, - "webSearch" => ThreadItem::WebSearch { - item_id, - query: field_text(root, item, "query").or_else(|| { - item.get("action") - .and_then(|action| action.get("query")) - .and_then(value_text) - .map(|query| detail_text(root, &query)) - }), - output: field_text(root, item, "output"), - at, - }, - "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, - other => ThreadItem::Other { - item_id, - raw_type: other.to_string(), - at, - }, - }) -} - -/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 -/// -/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn thread_items_from_history( - root: &Path, - items: &[Value], - timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { - items - .iter() - .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) - .collect() -} - -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_carries_role_text_and_turn() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - 0, - ) - .expect("user item"); - assert_eq!( - item, - ThreadItem::Message { - item_id: "direct-codex:turn-1:user".to_string(), - role: "user".to_string(), - text: "做一个拼图游戏".to_string(), - at: 0, - } - ); - } - - #[test] - fn app_server_agent_message_defaults_to_assistant_role() { - let item = thread_item_from_value( - root(), - &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), - 1000, - ) - .expect("agent message"); - assert!(matches!( - item, - ThreadItem::Message { role, .. } if role == "assistant" - )); - } - - #[test] - fn tool_item_identity_is_normalized_to_one_id() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - 1000, - ) - .expect("tool item"); - // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 - assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - assert!(matches!( - item, - ThreadItem::FunctionCall { name, arguments, .. } - if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" - )); - } - - #[test] - fn command_execution_keeps_raw_status_and_exit_code() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "call-1", - "type": "commandExecution", - "command": "ls", - "status": "failed", - "exitCode": 2, - "aggregatedOutput": "boom", - }), - 0, - ) - .expect("command item"); - assert!(matches!( - item, - ThreadItem::CommandExecution { - command, - output: Some(output), - status: Some(status), - exit_code: Some(2), - .. - } if command == "ls" && output == "boom" && status == "failed" - )); - } - - #[test] - fn secrets_and_absolute_paths_are_not_leaked() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], - }), - 0, - ) - .expect("assistant item"); - let ThreadItem::Message { text, .. } = item else { - panic!("message item"); - }; - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); - } - - #[test] - fn history_keeps_call_and_output_as_two_items_with_one_identity() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let projected = thread_items_from_history(root(), &items, |_| 0); - assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); - assert!(matches!( - projected[1], - ThreadItem::FunctionCallOutput { .. } - )); - // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 - assert_eq!(projected[0].item_id(), projected[1].item_id()); - assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - } - - #[test] - fn unknown_item_types_are_passed_through_without_body() { - let item = thread_item_from_value( - root(), - &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), - 0, - ) - .expect("unknown item"); - assert!(matches!( - item, - // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 - // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 - ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" - )); - } - - /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, - /// 后者只说明条目什么时候被看到。 - #[test] - fn event_stage_time_is_independent_from_item_display_time() { - let params = json!({ - "completedAtMs": 2_000u64, - "item": { - "id": "call-1", - "type": "commandExecution", - "command": "ls", - "startedAtMs": 1_000u64, - }, - }); - let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); - // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 - assert_eq!(item.at(), 1_000); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), - 2_000 - ); - } - - #[test] - fn item_started_event_at_uses_notification_stage_time() { - // 通知层 `startedAtMs` 优先于条目自带的同名字段。 - let params = json!({ - "threadId": "thread-1", - "turnId": "turn-1", - "startedAtMs": 1_700_000_000_123u64, - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_700_000_000_000u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), - 1_700_000_000_123 - ); - } - - #[test] - fn item_event_at_falls_back_to_nested_item_then_host_clock() { - let nested = json!({ - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_700_000_000_500u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), - 1_700_000_000_500 - ); - - // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 - let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); - assert_eq!( - thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), - 9_999 - ); - assert_eq!( - thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), - 9_999 - ); - } - - #[test] - fn item_completed_event_at_prefers_completion_over_start() { - // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 - let params = json!({ - "startedAtMs": 1_000u64, - "completedAtMs": 2_000u64, - "durationMs": 1_000u64, - "item": {"id": "call-1", "type": "commandExecution"}, - }); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), - 2_000 - ); - - // 完成时间只在条目里:同样取完成时间。 - let nested = json!({ - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_000u64, - "completedAtMs": 2_500u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), - 2_500 - ); - } - - #[test] - fn item_completed_event_at_derives_end_only_with_reliable_start() { - let with_start = json!({ - "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, - }); - assert_eq!( - thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), - 1_250 - ); - - // 只有时长不足以证明结束时刻:回落到宿主钟。 - let duration_only = json!({ - "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, - }); - assert_eq!( - thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), - 9_999 - ); - } - - /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, - /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 - /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 - #[test] - fn turn_completed_at_ignores_second_truncated_upstream_fields() { - let seconds_only = json!({ - "id": "turn-1", - "status": "completed", - "startedAt": 1_700_000_000i64, - "completedAt": 1_700_000_042i64, - }); - assert_eq!( - thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), - 9_999, - "没有 durationMs 时用宿主钟,不换算秒字段" - ); - assert_eq!( - thread_turn_completed_at_ms(&seconds_only, None, 9_999), - 9_999 - ); - } - - #[test] - fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { - let with_duration = json!({ - "id": "turn-1", - "status": "completed", - "startedAt": 1_700_000_000i64, - "completedAt": 1_700_000_042i64, - "durationMs": 42_500u64, - }); - // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 - assert_eq!( - thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), - 1_700_000_043_000 - ); - // 起点不可证明时不派生。 - assert_eq!( - thread_turn_completed_at_ms(&with_duration, None, 9_999), - 9_999 - ); - // 时长为 0 同样按缺失处理。 - let zero_duration = json!({"durationMs": 0u64}); - assert_eq!( - thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), - 9_999 - ); - } - - /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 - /// 反序列化仍成立,且不会序列化出 `at: null`。 - #[test] - fn lifecycle_events_serialize_event_level_at_as_optional_number() { - let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) - .expect("serialize turn.started"); - assert_eq!( - started, - json!({"type": "turn.started", "at": 1_700_000_000_123u64}) - ); - assert_eq!( - serde_json::from_value::(started).expect("round trip"), - ThreadEvent::turn_started(1_700_000_000_123) - ); - - let completed = - serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) - .expect("serialize turn.completed"); - assert_eq!( - completed, - json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) - ); - - let item = ThreadItem::CommandExecution { - item_id: "call-1".to_string(), - command: "ls".to_string(), - output: None, - status: Some("completed".to_string()), - exit_code: None, - at: 1_500, - }; - let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) - .expect("serialize item.started"); - assert_eq!(item_started["at"], json!(1_000u64)); - // 事件级 `at` 不动条目自己的展示时间。 - assert_eq!(item_started["item"]["at"], json!(1_500u64)); - let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) - .expect("serialize item.completed"); - assert_eq!(item_completed["at"], json!(2_000u64)); - - // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) - .expect("legacy turn.started without at"); - assert_eq!( - legacy, - ThreadEvent::TurnStarted { - at: None, - user_item_id: None, - } - ); - assert_eq!(legacy.at(), None); - assert_eq!(legacy.user_item_id(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({"type": "turn.started"}) - ); - assert_eq!( - serde_json::to_value(ThreadEvent::request( - ThreadRequestKind::RequestResolved, - None, - )) - .expect("serialize request"), - json!({"type": "request", "kind": "request.resolved", "requestId": null}) - ); - } - - /// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), - /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 - #[test] - fn lifecycle_events_carry_optional_opener_user_item_id() { - let started = - ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&started).expect("serialize turn.started"), - json!({ - "type": "turn.started", - "at": 1_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&started).expect("serialize") - ) - .expect("round trip"), - started - ); - - let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) - .with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&completed).expect("serialize turn.completed"), - json!({ - "type": "turn.completed", - "status": "interrupted", - "at": 2_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - // 起止同源:同一轮的两条边界带同一个身份。 - assert_eq!(started.user_item_id(), completed.user_item_id()); - - // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 - for empty in ["", " "] { - let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); - assert_eq!(event.user_item_id(), None); - assert_eq!( - serde_json::to_value(&event).expect("serialize"), - json!({"type": "turn.started", "at": 1_000u64}) - ); - } - - // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({ - "type": "turn.completed", - "status": "completed", - "at": 3_000u64, - })) - .expect("legacy turn.completed without userItemId"); - assert_eq!(legacy.user_item_id(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) - ); - - // 条目事件没有这个字段:身份只在生命周期事件上。 - let item_event = ThreadEvent::item_completed( - ThreadItem::CommandExecution { - item_id: "call-1".to_string(), - command: "ls".to_string(), - output: None, - status: None, - exit_code: None, - at: 1_500, - }, - 1_600, - ); - assert_eq!(item_event.user_item_id(), None); - } - - /// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份 - /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 - #[test] - fn turn_completed_carries_failure_payload_only_when_failed() { - let failed = ThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::ModelFailed, - "上游返回 500:模型服务暂不可用", - ), - 4_000, - ) - .with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!( - failed.failure(), - Some(&DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::ModelFailed, - "上游返回 500:模型服务暂不可用" - )) - ); - assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&failed).expect("serialize failed turn"), - json!({ - "type": "turn.completed", - "status": "failed", - "failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"}, - "at": 4_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&failed).expect("serialize") - ) - .expect("round trip"), - failed - ); - - // 正常终态不带载荷,也不回写 `failure: null`。 - let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); - assert_eq!(completed.failure(), None); - assert_eq!( - serde_json::to_value(&completed).expect("serialize completed turn"), - json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) - ); - - // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 - let sparse: ThreadEvent = serde_json::from_value(json!({ - "type": "turn.completed", - "status": "failed", - })) - .expect("failed turn without failure payload"); - assert_eq!(sparse.failure(), None); - } - - /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, - /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 - #[test] - fn queue_removed_carries_a_typed_reason() { - let dispatched = - ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); - assert_eq!( - serde_json::to_value(&dispatched).expect("serialize queue.removed"), - json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "dispatched", - "at": 2_000u64, - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&dispatched).expect("serialize") - ) - .expect("round trip"), - dispatched - ); - assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); - assert_eq!( - dispatched.queue_removal_reason(), - Some(QueueRemovalReason::Dispatched) - ); - - // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 - serde_json::from_value::(json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "timeout", - })) - .expect_err("unknown queue removal reason must fail closed"); - - // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "cancelled", - })) - .expect("queue.removed without at"); - assert_eq!(legacy.at(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "cancelled", - }) - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs new file mode 100644 index 000000000..781f909e9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs @@ -0,0 +1,8 @@ +/// 宿主观测时刻:Unix 毫秒。 +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs new file mode 100644 index 000000000..deb44460a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -0,0 +1,50 @@ +//! 失败终态的**线上载荷**。 +//! +//! [`TurnFailure`] 是 `turn.completed.status == "failed"` 时必有的下发形状,形状属于线上协议, +//! 所以留在这个 wire 模块里;`turn.completed` 事件本身在 [`super::turn`]。 +//! +//! "什么算失败、原因怎么写"是宿主内部策略,不在 wire 里:那是 +//! [`crate::agent::TurnCompletion`](`thread_manager::turn_completion`)。 +//! +//! 载荷与脱敏都由 [`TurnError::classify`] 一处投影:Rust 侧没有第二个地方再拼它,也没有任何 +//! 地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::agent::{ + EnvironmentNotReady, HostStateUnavailable, ModelCallFailed, ProjectRootUnanchored, + SuperErrorFromStringPlusStage, TimedOut, TransportClosed, TurnInterrupted, Unclassified, +}; + +/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 +/// +/// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 +/// 不直接上屏。 +/// +/// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 +/// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 +/// 不需要为它写分支。 +// TODO badnaming: 这个名字没有表达出它只是"回合终态的失败载荷",先留着待改名。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum TurnFailure { + ProjectRootUnanchored(ProjectRootUnanchored), + EnvironmentNotReady(EnvironmentNotReady), + HostStateUnavailable(HostStateUnavailable), + ModelCallFailed(ModelCallFailed), + TransportClosed(TransportClosed), + TimedOut(TimedOut), + TurnInterrupted(TurnInterrupted), + SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), + Unclassified(Unclassified), + /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 + HostDropped, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs new file mode 100644 index 000000000..2d94f084b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs @@ -0,0 +1,479 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use ts_rs::TS; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的原样投影(脱敏与限长在事件进前端状态时统一做)。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl ThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl ThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化。脱敏不在这一层做。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(item: &Value) -> Option { + item.get("text") + .and_then(Value::as_str) + // 空 / 全空白的 `text` 要在**回落之前**判掉:否则 `Some("")` 会短路 `content` / `summary` + // 兜底,末尾那条非空过滤再把整条条目丢掉(message / reasoning 的正文就此消失)。 + .filter(|text| !text.trim().is_empty()) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(ThreadFileChange { + path: path.to_string(), + kind: change + .get("kind") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| "update".to_string()), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(item: &Value, key: &str) -> Option { + item.get(key).and_then(value_text) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +/// +/// 这一层**原样透传**字段,不做脱敏、也不限长:脱敏在事件进前端状态时统一做(前端 +/// `directThreadSanitize`)。Thread Manager 只归一身形与身份,不替前端决定什么能看。 +pub(crate) fn thread_item_from_value(item: &Value, observed_at_ms: u64) -> Option { + if !item.is_object() { + return None; + } + let item_id = thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => ThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => ThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => ThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(), + arguments: field_text(item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => ThreadItem::FunctionCallOutput { + item_id, + output: field_text(item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => ThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(str::to_string) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => ThreadItem::FileChange { + item_id, + changes: item_changes(item), + at, + }, + "mcpToolCall" => ThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(str::to_string) + .unwrap_or_default(), + arguments: field_text(item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => ThreadItem::WebSearch { + item_id, + query: field_text(item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + }), + output: field_text(item, "output"), + at, + }, + "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, + other => ThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn thread_items_from_history( + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| thread_item_from_value(item, timestamp_of(item))) + .collect() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs new file mode 100644 index 000000000..916fac81d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs @@ -0,0 +1,31 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 +//! +//! 文件按职责拆开: +//! - [`items`]:条目与历史切片的投影、脱敏、截断; +//! - [`turn`]:事件、订阅 / 消费载荷与队列出队语义; +//! - [`failure`]:失败终态的线上载荷; +//! - [`clock`]:宿主观测钟。 + +mod clock; +mod failure; +mod items; +mod turn; + +#[cfg(test)] +mod tests; + +pub(crate) use clock::*; +pub(crate) use failure::*; +pub(crate) use items::*; +pub(crate) use turn::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs new file mode 100644 index 000000000..e809cc22f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs @@ -0,0 +1,583 @@ +use super::clock::*; +use super::{failure::*, items::*, turn::*}; +use serde_json::json; + +#[test] +fn message_item_carries_role_text_and_turn() { + let item = thread_item_from_value( + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + ThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); +} + +#[test] +fn app_server_agent_message_defaults_to_assistant_role() { + let item = thread_item_from_value( + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + ThreadItem::Message { role, .. } if role == "assistant" + )); +} + +#[test] +fn blank_message_text_falls_back_to_content_parts() { + // 空 / 全空白的 `text` 不能短路 `content` 兜底:否则整条条目会被判成"没有正文"直接丢掉。 + let item = thread_item_from_value( + &json!({ + "id": "msg-blank-text", + "type": "message", + "role": "assistant", + "text": " ", + "content": [{"type": "output_text", "text": "真实正文"}], + }), + 1000, + ) + .expect("blank text must still fall back to content"); + assert!(matches!( + item, + ThreadItem::Message { text, .. } if text == "真实正文" + )); +} + +#[test] +fn tool_item_identity_is_normalized_to_one_id() { + let item = thread_item_from_value( + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + ThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); +} + +#[test] +fn command_execution_keeps_raw_status_and_exit_code() { + let item = thread_item_from_value( + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + ThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); +} + +#[test] +fn item_text_is_passed_through_verbatim() { + // Thread Manager 不再脱敏 / 限长:原始正文原样下发,脱敏在事件进前端状态时统一做。 + let raw = "key=sk-abcdefghijklmnop at /root/secret/x"; + let item = thread_item_from_value( + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": raw}], + }), + 0, + ) + .expect("assistant item"); + let ThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert_eq!(text, raw); +} + +#[test] +fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = thread_items_from_history(&items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); + assert!(matches!( + projected[1], + ThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); +} + +#[test] +fn unknown_item_types_are_passed_through_without_body() { + let item = thread_item_from_value( + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); +} + +/// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, +/// 后者只说明条目什么时候被看到。 +#[test] +fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = thread_item_from_value(¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); +} + +#[test] +fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); +} + +#[test] +fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); +} + +#[test] +fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); +} + +#[test] +fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); +} + +/// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, +/// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 +/// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 +#[test] +fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); +} + +#[test] +fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); +} + +/// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 +/// 反序列化仍成立,且不会序列化出 `at: null`。 +#[test] +fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + ThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = + serde_json::to_value(ThreadEvent::turn_completed(TurnOutcome::Completed, 2_000)) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = ThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!( + legacy, + ThreadEvent::TurnStarted { + at: None, + user_item_id: None, + } + ); + assert_eq!(legacy.at(), None); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(ThreadEvent::request( + ThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); +} + +/// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), +/// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 +#[test] +fn lifecycle_events_carry_optional_opener_user_item_id() { + let started = + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&started).expect("serialize turn.started"), + json!({ + "type": "turn.started", + "at": 1_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&started).expect("serialize")) + .expect("round trip"), + started + ); + + let completed = ThreadEvent::turn_completed(TurnOutcome::Interrupted, 2_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&completed).expect("serialize turn.completed"), + json!({ + "type": "turn.completed", + "status": "interrupted", + "at": 2_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + // 起止同源:同一轮的两条边界带同一个身份。 + assert_eq!(started.user_item_id(), completed.user_item_id()); + + // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 + for empty in ["", " "] { + let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + assert_eq!(event.user_item_id(), None); + assert_eq!( + serde_json::to_value(&event).expect("serialize"), + json!({"type": "turn.started", "at": 1_000u64}) + ); + } + + // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "completed", + "at": 3_000u64, + })) + .expect("legacy turn.completed without userItemId"); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) + ); + + // 条目事件没有这个字段:身份只在生命周期事件上。 + let item_event = ThreadEvent::item_completed( + ThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: None, + exit_code: None, + at: 1_500, + }, + 1_600, + ); + assert_eq!(item_event.user_item_id(), None); +} + +/// 失败终态:`status="failed"` 必须带 typed 的 `failure` 载荷,正常终态不带;载荷跟着身份 +/// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 +#[test] +fn turn_completed_carries_failure_payload_only_when_failed() { + let payload = TurnFailure::ModelCallFailed(crate::agent::ModelCallFailed { + kind: crate::agent::ModelCallKind::UpstreamFailed { + status_code: 500, + native: None, + }, + detail: "上游返回 500:模型服务暂不可用".to_string(), + }); + let failed = ThreadEvent::turn_completed_failed(payload.clone(), 4_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(failed.failure(), Some(&payload)); + assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&failed).expect("serialize failed turn"), + json!({ + "type": "turn.completed", + "status": "failed", + "failure": { + "type": "modelCallFailed", + "kind": {"type": "upstreamFailed", "statusCode": 500, "native": null}, + "detail": "上游返回 500:模型服务暂不可用" + }, + "at": 4_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&failed).expect("serialize")) + .expect("round trip"), + failed + ); + + // 正常终态不带载荷,也不回写 `failure: null`。 + let completed = ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000); + assert_eq!(completed.failure(), None); + assert_eq!( + serde_json::to_value(&completed).expect("serialize completed turn"), + json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) + ); + + // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 + let sparse: ThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "failed", + })) + .expect("failed turn without failure payload"); + assert_eq!(sparse.failure(), None); +} + +/// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, +/// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 +#[test] +fn queue_removed_carries_a_typed_reason() { + let dispatched = + ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(QueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs new file mode 100644 index 000000000..be73d909f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs @@ -0,0 +1,395 @@ +use super::failure::TurnFailure; +use super::items::{ThreadDeltaKind, ThreadItem, ThreadRequestKind}; +use crate::agent::UserItem; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + +/// `turn.completed` 的终态取值。 +/// +/// 正常收场是前三档、不带载荷;`Failed` **一定**带 [`TurnFailure`] 载荷,只由 +/// [`ThreadEvent::turn_completed_failed`] 写出。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum TurnCompletedStatus { + Completed, + Interrupted, + Aborted, + Failed, +} + +/// 正常收场的三档(不含失败):只作 [`ThreadEvent::turn_completed`] 的构造参数,不进线上形状。 +/// +/// 失败必须带载荷,所以这里没有 `Failed`——这就是 [`ThreadEvent::turn_completed`] 写不出 +/// "failed 但没有载荷"的原因。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TurnOutcome { + Completed, + Interrupted, + Aborted, +} + +impl From for TurnCompletedStatus { + fn from(outcome: TurnOutcome) -> Self { + match outcome { + TurnOutcome::Completed => Self::Completed, + TurnOutcome::Interrupted => Self::Interrupted, + TurnOutcome::Aborted => Self::Aborted, + } + } +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 +/// +/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical +/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; +/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 +/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 +/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 +/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted { + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// `turn/start` 的时刻)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "turn.completed")] + TurnCompleted { + /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场,不带载荷;`failed` 是 + /// **失败**,一定带 `failure` 载荷。正常收场的构造参数是 [`TurnOutcome`](不含 `Failed`), + /// [`ThreadEvent::turn_completed`] 因此写不出"failed 但没有载荷"。 + status: TurnCompletedStatus, + /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + failure: Option, + /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "item.started")] + ItemStarted { + item: ThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.completed")] + ItemCompleted { + item: ThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: ThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: ThreadRequestKind, + #[serde(default)] + request_id: Option, + }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 + /// 从这条条目重投影。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: UserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: QueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, +} + +impl ThreadEvent { + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } + } + + pub(crate) fn turn_completed(status: TurnOutcome, at: u64) -> Self { + Self::TurnCompleted { + status: status.into(), + failure: None, + at: Some(at), + user_item_id: None, + } + } + + /// 失败终态:`status` 固定 `failed`,原因必须随事件一起带出去。 + pub(crate) fn turn_completed_failed(failure: TurnFailure, at: u64) -> Self { + Self::TurnCompleted { + status: TurnCompletedStatus::Failed, + failure: Some(failure), + at: Some(at), + user_item_id: None, + } + } + + /// 失败载荷:只有失败终态有。 + pub(crate) fn failure(&self) -> Option<&TurnFailure> { + match self { + Self::TurnCompleted { failure, .. } => failure.as_ref(), + _ => None, + } + } + + /// 附上本轮开口用户条目的 canonical itemId。 + /// + /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 + /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 + pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { + let user_item_id = user_item_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match self { + Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, + Self::TurnCompleted { + status, + failure, + at, + .. + } => Self::TurnCompleted { + status, + failure, + at, + user_item_id, + }, + other => other, + } + } + + /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 + #[cfg(test)] + pub(crate) fn user_item_id(&self) -> Option<&str> { + match self { + Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { + user_item_id.as_deref() + } + _ => None, + } + } + + pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } + } + + pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } + } + + pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: UserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: QueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + #[cfg(test)] + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at, .. } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct SubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 214a360e5..c45f849b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at( file_name: &str, media_type: &str, bytes: &[u8], +) -> Result { + upload_local_asset_at_with_category(root, file_name, media_type, bytes, None) +} + +/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。 +/// +/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), +/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相, +/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。 +/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]: +/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。 +pub(crate) fn upload_local_asset_at_with_category( + root: &Path, + file_name: &str, + media_type: &str, + bytes: &[u8], + target_category: Option<&str>, ) -> Result { if bytes.is_empty() { return Err("上传文件不能为空".to_string()); @@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at( } crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?; - register_local_asset_entry( + register_local_asset_entry_with_category( root, &relative_path, uploaded_asset_kind(file_name, media_type), @@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at( generation_kind: None, reference_resource_ids: Vec::new(), }, + target_category, ) } @@ -2375,6 +2393,70 @@ mod tests { ); } + /// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。 + /// + /// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`; + /// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为, + /// 非法值失败关闭且不留下半成品登记。 + #[test] + fn upload_registers_into_the_explicit_entry_category() { + fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("uploaded asset is registered") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目") + .expect("init project"); + + let uploaded = upload_local_asset_at_with_category( + root, + "hero.png", + "image/png", + b"png-bytes", + Some("character"), + ) + .expect("upload with an entry category"); + assert_eq!( + upload_category(root, &uploaded.id), + GameCreationAppAssetCategory::Character + ); + + let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes") + .expect("upload without an entry category"); + assert_eq!( + upload_category(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + + // 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。 + let assets_before = read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(); + assert!(upload_local_asset_at_with_category( + root, + "bad.png", + "image/png", + b"png-bytes", + Some("version"), + ) + .is_err()); + assert_eq!( + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .len(), + assets_before + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 #[test] fn canvas_export_asset_kind_is_always_canonical() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 098f43841..712c41fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,4 +1,5 @@ use std::fs; +use std::future::Future; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] @@ -120,8 +121,19 @@ enum OwnedBrowserLaunchError { } impl OwnedBrowserLaunchError { - fn is_timeout(self) -> bool { - matches!(self, Self::WsTimeout | Self::ConnectTimeout) + fn stage(self) -> BrowserLaunchStage { + match self { + Self::SpawnFailed => BrowserLaunchStage::Spawn, + Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + } + } + + fn is_recoverable(self) -> bool { + matches!( + self, + Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + ) } fn code(self) -> &'static str { @@ -135,6 +147,118 @@ impl OwnedBrowserLaunchError { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BrowserLaunchStage { + Setup, + Spawn, + WsHandshake, + CdpConnect, +} + +impl BrowserLaunchStage { + fn as_str(self) -> &'static str { + match self { + Self::Setup => "setup", + Self::Spawn => "spawn", + Self::WsHandshake => "ws-handshake", + Self::CdpConnect => "cdp-connect", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserLaunchFailure { + code: &'static str, + stage: BrowserLaunchStage, + recoverable: bool, + subprocess_exited: bool, + cleanup_confirmed: bool, +} + +impl BrowserLaunchFailure { + fn setup(code: &'static str) -> Self { + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + } + } + + fn from_launch_error( + error: OwnedBrowserLaunchError, + subprocess_exited: bool, + cleanup_confirmed: bool, + ) -> Self { + Self { + code: error.code(), + stage: error.stage(), + recoverable: error.is_recoverable(), + subprocess_exited, + cleanup_confirmed, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserRecoveryFailure { + first: BrowserLaunchFailure, + final_attempt: Option, +} + +impl BrowserRecoveryFailure { + fn diagnostic(self) -> String { + match self.final_attempt { + Some(final_attempt) => format!( + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + self.first.stage.as_str(), + final_attempt.stage.as_str(), + final_attempt.subprocess_exited, + final_attempt.cleanup_confirmed, + self.first.code, + final_attempt.code, + ), + None => format!( + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + if self.first.recoverable { + "browser-recovery-blocked" + } else { + "browser-launch-failed" + }, + self.first.stage.as_str(), + self.first.subprocess_exited, + self.first.cleanup_confirmed, + self.first.code, + ), + } + } +} + +async fn launch_with_one_recovery(mut launch: F) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + let first = match launch().await { + Ok(value) => return Ok(value), + Err(error) => error, + }; + if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed { + return Err(BrowserRecoveryFailure { + first, + final_attempt: None, + }); + } + match launch().await { + Ok(value) => Ok(value), + Err(final_attempt) => Err(BrowserRecoveryFailure { + first, + final_attempt: Some(final_attempt), + }), + } +} + type BrowserStderrReader = futures::io::BufReader; /// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 @@ -224,22 +348,22 @@ impl BrowserProcessGuard { async fn confirm_reaped(&mut self) -> Result<(), String> { #[cfg(windows)] { - if let Some(job) = &self.job { - let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; - loop { - if job - .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? - { - return Ok(()); - } - if Instant::now() >= deadline { - return Err("browser-tree-reap-unconfirmed".into()); - } - tokio::time::sleep(Duration::from_millis(20)).await; + let Some(job) = &self.job else { + return Err("browser-tree-reap-unconfirmed".into()); + }; + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; } - Ok(()) } #[cfg(not(windows))] { @@ -281,24 +405,34 @@ impl OwnedBrowser { let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; self.handler_task.abort(); self.drain_task.abort(); - if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { + if matches!(close, Ok(Ok(_))) + && matches!(waited, Ok(Ok(_))) + && self.process.confirm_reaped().await.is_ok() + { return Ok(()); } - if !self.process.reap().await { + let subprocess_exited = self.process.reap().await; + if !subprocess_exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err("browser-cleanup-unconfirmed".into()); + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - let confirmed = self.process.confirm_reaped().await; - if confirmed.is_err() { + if self.process.confirm_reaped().await.is_err() { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - confirmed + Ok(()) } } @@ -310,24 +444,112 @@ impl Drop for OwnedBrowser { } } -/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 -/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; -/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 -/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 -/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn cleanup_failed_launch( + mut process: BrowserProcessGuard, + temp: TempDir, + error: OwnedBrowserLaunchError, + tree_control_confirmed: bool, +) -> BrowserLaunchFailure { + let subprocess_exited = process.reap().await; + let tree_reaped = if tree_control_confirmed && subprocess_exited { + #[cfg(windows)] + { + process.job.is_none() || process.confirm_reaped().await.is_ok() + } + #[cfg(not(windows))] + { + process.confirm_reaped().await.is_ok() + } + } else { + false + }; + drop(process); + let cleanup_confirmed = if tree_reaped { + temp.close().is_ok() + } else { + // 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。 + let _ = temp.keep(); + false + }; + BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) +} + +/// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE +/// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动 +/// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp: TempDir, -) -> Result { - let child = config - .launch() - .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; - // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 +) -> Result { + let child = match config.launch() { + Ok(child) => child, + Err(_) => { + return Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::SpawnFailed, + true, + true, + )); + } + }; + #[cfg(windows)] + let root_identity = child.inner.id().and_then(|pid| { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + }); #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { - Ok(job) => Some(job), - Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + Ok(job) => { + let (Some(root_pid), Some(root_identity)) = + (child.inner.id(), root_identity.as_deref()) + else { + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + false, + ) + .await); + }; + if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() { + Some(job) + } else { + let tree_reaped = + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok(); + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } + } + Err(_) => { + let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { + (Some(root_pid), Some(root_identity)) => { + super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() + } + _ => false, + }; + let process = BrowserProcessGuard { child, job: None }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } }; let mut process = BrowserProcessGuard { child, @@ -336,17 +558,13 @@ async fn launch_owned_browser( }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { - super::sweep::write_browser_process_owner(temp.path(), pid, executable); + let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable); } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, Err(error) => { - // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 - if !process.reap().await { - std::mem::forget(temp); - } - return Err(error); + return Err(cleanup_failed_launch(process, temp, error, true).await); } }; let drain_task = spawn_stderr_drain(reader); @@ -359,17 +577,23 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectFailed); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectFailed, + true, + ) + .await); } Err(_) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectTimeout); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectTimeout, + true, + ) + .await); } }; let handler_task = tokio::spawn(async move { @@ -388,22 +612,31 @@ async fn launch_owned_browser( }) } +async fn launch_browser_attempt( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + let temporary = create_browser_process_temp_dir() + .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; + let config = browser_config(executable, &temporary, proxy_bypass_list) + .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + launch_owned_browser(config, executable, temporary).await +} + +async fn launch_browser_with_recovery( + executable: &std::path::Path, + proxy_bypass_list: &str, +) -> Result { + launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list)) + .await + .map_err(|failure| failure.diagnostic()) +} + /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - let owned = launch_owned_browser(config, &discovered.executable_path, temporary) - .await - .map_err(|error| { - if error.is_timeout() { - "browser-start-timeout" - } else { - "browser-start-failed" - } - })?; + let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); @@ -460,23 +693,19 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; - let browser_temp = create_browser_process_temp_dir()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); - let config = browser_config( - &browser_executable.executable_path, - &browser_temp, - &proxy_bypass_list, - )?; - - let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) - .await - .map_err(|error| { - if error.is_timeout() { - "启动浏览器超时".to_string() - } else { - format!("启动浏览器失败:{}", error.code()) - } - })?; + let owned = + launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list) + .await + .map_err(|error| { + if error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed") + { + error + } else { + format!("启动浏览器失败:{error}") + } + })?; let work = run_browser_validation( owned.browser(), @@ -499,10 +728,8 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - if owned.shutdown().await.is_err() { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); + if let Err(error) = owned.shutdown().await { + return Err(error); } let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); @@ -583,6 +810,88 @@ mod health_tests { assert_eq!(config.viewport, Some(Viewport::default())); } + #[tokio::test] + async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let profiles_for_launch = std::sync::Arc::clone(&profiles); + let result = launch_with_one_recovery(move || { + let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let profiles = std::sync::Arc::clone(&profiles_for_launch); + async move { + let temporary = tempfile::tempdir().unwrap(); + profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push(temporary.path().to_path_buf()); + if attempt == 0 { + drop(temporary); + Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + } else { + drop(temporary); + Ok(()) + } + } + }) + .await; + assert!(result.is_ok()); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2); + let profiles = profiles + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + assert_eq!(profiles.len(), 2); + assert_ne!(profiles[0], profiles[1]); + } + + #[tokio::test] + async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() { + let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let attempts_for_launch = std::sync::Arc::clone(&attempts); + let failure = launch_with_one_recovery(move || { + attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::ConnectTimeout, + false, + false, + )) + } + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1); + assert!(diagnostic.contains("stage=cdp-connect")); + assert!(diagnostic.contains("subprocess-exited=false")); + assert!(diagnostic.contains("cleanup-confirmed=false")); + assert!(diagnostic.contains("recovery-retried=false")); + } + + #[tokio::test] + async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { + let failure = launch_with_one_recovery(|| async { + Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::WsFailed, + true, + true, + )) + }) + .await + .unwrap_err(); + let diagnostic = failure.diagnostic(); + assert!(diagnostic.contains("initial-stage=ws-handshake")); + assert!(diagnostic.contains("final-stage=ws-handshake")); + assert!(diagnostic.contains("subprocess-exited=true")); + assert!(diagnostic.contains("cleanup-confirmed=true")); + assert!(diagnostic.contains("recovery-retried=true")); + assert!(!diagnostic.contains("/tmp")); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index d33a4a2ef..0a66c5762 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use super::discovery::system_browser_candidates; use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; const OWNER_FILE: &str = "owner.json"; const OWNER_SCHEMA: &str = "agc-browser-process.v1"; @@ -34,13 +36,17 @@ pub(super) struct BrowserProcessOwner { /// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, /// 该目录之后按旧残留只删不杀。 -pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { +pub(super) fn write_browser_process_owner( + temp_root: &Path, + browser_pid: u32, + executable: &Path, +) -> bool { let identity = crate::process_identity::external_agent_runner_process_start_identity(browser_pid); let owner_identity = crate::process_identity::external_agent_runner_process_start_identity(std::process::id()); let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { - return; + return false; }; let owner = BrowserProcessOwner { schema_version: OWNER_SCHEMA.into(), @@ -52,9 +58,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex created_unix_ms: super::evidence::unix_time_ms(), }; let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { - return; + return false; }; - let _ = fs::write(temp_root.join(OWNER_FILE), bytes); + fs::write(temp_root.join(OWNER_FILE), bytes).is_ok() } fn read_browser_process_owner(dir: &Path) -> Option { @@ -115,34 +121,142 @@ fn trusted_browser_executable(path: &Path) -> bool { } /// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 -/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 -/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 -/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 -/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC +/// 实例或用户 Edge 误认成本轮浏览器。 #[cfg(windows)] -fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { - // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 - // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 +fn windows_process_command_line(pid: u32) -> Option { + use std::ffi::c_void; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *const u16, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtQueryInformationProcess( + process: *mut c_void, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + } + + let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if process.is_null() { + return None; + } + let mut required = 0; + let _ = + unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 || required > 64 * 1024 { + unsafe { CloseHandle(process) }; + return None; + } + let mut buffer = vec![0u8; required as usize]; + let status = unsafe { + NtQueryInformationProcess( + process, + 60, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + }; + unsafe { CloseHandle(process) }; + if status != 0 { + return None; + } + let command_line = unsafe { &*(buffer.as_ptr().cast::()) }; + if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 { + return None; + } + let text = unsafe { + std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2) + }; + String::from_utf16(text).ok() +} + +#[cfg(windows)] +fn windows_command_line_arguments(command_line: &str) -> Option> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + + let wide = command_line + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut count = 0; + let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) }; + if argv.is_null() || count < 0 { + if !argv.is_null() { + unsafe { LocalFree(argv.cast()) }; + } + return None; + } + let result = unsafe { std::slice::from_raw_parts(argv, count as usize) } + .iter() + .map(|argument| { + if argument.is_null() { + return None; + } + let mut length = 0; + unsafe { + while *argument.add(length) != 0 { + length += 1; + } + String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok() + } + }) + .collect::>>(); + unsafe { LocalFree(argv.cast()) }; + result +} + +#[cfg(windows)] +fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool { + const MARKER: &str = "--user-data-dir="; + let Some(arguments) = windows_command_line_arguments(command_line) else { + return false; + }; + arguments.iter().enumerate().any(|(index, argument)| { + let value = argument.strip_prefix(MARKER).or_else(|| { + (argument == "--user-data-dir") + .then(|| arguments.get(index + 1).map(String::as_str)) + .flatten() + }); + value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir)) + }) +} + +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, }; - // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return false; } let mut buffer = [0u16; 1024]; let mut length = buffer.len() as u32; - // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; - // SAFETY: handle 是本函数持有的合法句柄。 unsafe { CloseHandle(handle) }; if okay == 0 || length == 0 { return false; } let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); same_executable_path(&actual, expected) + && windows_process_command_line(pid) + .is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir)) } #[cfg(target_os = "linux")] @@ -181,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool { .unwrap_or(false) } -fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { +pub(super) fn kill_browser_process_tree( + root_pid: u32, + expected_identity: &str, +) -> Result<(), String> { #[cfg(windows)] { // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 @@ -190,15 +307,17 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( for _ in 0..TREE_KILL_MAX_PASSES { let snapshot = windows_process_snapshot()?; let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); - if root_present && process_identity_matches(root_pid, expected_identity) { + if root_present { + if !process_identity_matches(root_pid, expected_identity) { + return Err("browser-sweep-root-identity-mismatch".into()); + } // root 仍是目标浏览器:发现并追踪当前整棵子树。 for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { track_process(&mut tracked, pid); } - } else if !root_present { + } else { // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 - // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 - // 复用者的子进程。 + // PID 上的孤儿子进程。PID 已被复用时不会进入此分支。 for (pid, ppid) in &snapshot { if *ppid == root_pid { track_process(&mut tracked, *pid); @@ -323,6 +442,62 @@ fn windows_process_tree_pids_from( Ok(tree) } +#[cfg(windows)] +pub(super) fn ensure_browser_tree_in_job( + root_pid: u32, + root_identity: &str, + job: &WindowsProcessJob, +) -> Result<(), String> { + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + if !snapshot.iter().any(|(pid, _)| *pid == root_pid) + || !process_identity_matches(root_pid, root_identity) + { + return Err("browser-job-root-identity-unconfirmed".into()); + } + let tree = windows_process_tree_pids_from(&snapshot, root_pid)?; + if tree.is_empty() { + return Err("browser-job-root-exited-before-coverage".into()); + } + let mut members = Vec::with_capacity(tree.len()); + for pid in tree { + let identity = if pid == root_pid { + root_identity.to_string() + } else { + crate::process_identity::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())? + }; + members.push((pid, identity)); + } + for (pid, identity) in members { + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + if !job.contains_pid(pid)? { + job.assign_pid(pid)?; + } + if !process_identity_matches(pid, &identity) { + return Err("browser-job-process-identity-changed".into()); + } + } + + let verified = windows_process_snapshot()?; + let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?; + if process_identity_matches(root_pid, root_identity) + && !verified_tree.is_empty() + && verified_tree + .iter() + .all(|pid| job.contains_pid(*pid).unwrap_or(false)) + { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-job-tree-coverage-unconfirmed".into()) +} + #[cfg(windows)] fn windows_terminate_process(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; @@ -468,6 +643,18 @@ mod tests { } } + #[test] + fn owner_write_failure_leaves_no_partial_ownership_record() { + let root = tempfile::tempdir().unwrap(); + let missing_root = root.path().join("missing"); + assert!(!write_browser_process_owner( + &missing_root, + std::process::id(), + &std::env::current_exe().unwrap(), + )); + assert!(!missing_root.join(OWNER_FILE).exists()); + } + #[test] fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { let root = tempfile::tempdir().unwrap(); @@ -548,6 +735,90 @@ mod tests { assert!(trusted_browser_executable(&installed)); } + #[cfg(windows)] + #[test] + fn windows_browser_cleanup_requires_the_exact_profile_argument() { + let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#); + assert!(command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(command_line_contains_profile( + r#""msedge.exe" "--user-data-dir=C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#, + profile + )); + } + + #[cfg(windows)] + #[test] + fn browser_job_adopts_children_created_before_assignment() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let identity = crate::process_identity::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + std::thread::sleep(Duration::from_millis(500)); + let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job"); + ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree"); + let snapshot = windows_process_snapshot().expect("snapshot after adoption"); + let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption"); + assert!(tree + .iter() + .all(|member| job.contains_pid(*member).expect("job membership"))); + job.terminate().expect("terminate fixture job"); + let _ = child.wait(); + assert!(job.is_empty().expect("fixture job empty")); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_rejects_root_identity_mismatch() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args(["/c", "ping", "127.0.0.1", "-n", "60"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + let result = kill_browser_process_tree(pid, "not-the-fixture-identity"); + let _ = child.kill(); + let _ = child.wait(); + assert_eq!( + result.expect_err("identity mismatch must fail closed"), + "browser-sweep-root-identity-mismatch" + ); + } + #[cfg(windows)] #[test] fn kill_browser_process_tree_reaps_fixture_tree() { @@ -600,10 +871,8 @@ mod tests { fn live_process_executable_matches_current_process_image() { let exe = std::env::current_exe().unwrap(); let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); - // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 - // 不具备该标记,正例只在 Windows 断言。 - #[cfg(windows)] - assert!(live_process_executable_matches( + // 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。 + assert!(!live_process_executable_matches( std::process::id(), &exe, profile diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 9d8733862..cb5d1cf8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -1514,6 +1514,41 @@ async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_ } } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_reports_start_click_without_playing_phase() { + let result = run_stable_generic_viewport_fixture(false, Some( + r#""#, + )).await; + assert!(!result.passed); + assert!(result + .viewport_results + .iter() + .all(|viewport| viewport.passed)); + for entry in &result.viewport_playtests { + let playtest = &entry.result; + assert!(!playtest.passed); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert_eq!(playtest.final_sequence, Some(1)); + for (name, expected) in [ + ("start-control-clicked", true), + ("start-phase-playing", false), + ] { + assert_eq!( + playtest + .assertions + .iter() + .find(|assertion| assertion.name == name) + .unwrap() + .passed, + expected + ); + } + let reason = &playtest.diagnostics[0]; + assert!(reason.contains("必须进入 playing"), "{reason}"); + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, start_markup: Option<&str>, @@ -1556,7 +1591,7 @@ async fn run_stable_generic_viewport_fixture( }; document.querySelector('[data-playtest-id="start"]').addEventListener('click', (event) => { if (event.isTrusted && state.phase === 'ready') { - advance(() => { state.phase = 'playing'; }); + advance(() => { state.phase = event.currentTarget.dataset.startPhase || 'playing'; }); } }); const primary = document.querySelector('[data-playtest-id="primary-action"]'); @@ -1641,6 +1676,57 @@ async fn run_stable_generic_viewport_fixture( let persisted: serde_json::Value = serde_json::from_slice(&fs::read(&result.evidence.report_path).unwrap()).unwrap(); assert_eq!(persisted["viewportPlaytests"].as_array().unwrap().len(), 2); + let receipt = crate::agent::browser_tool_result( + evidence.path(), + &result, + Some(BrowserPlaytestScenario::GenericV1), + 1, + ); + assert_eq!(receipt["passed"], persisted["passed"]); + for (index, entry) in persisted["viewportPlaytests"] + .as_array() + .unwrap() + .iter() + .enumerate() + { + let projected = &receipt["gameplayResults"][index]; + assert_eq!(projected["viewport"], entry["viewport"]); + let viewport = entry["viewport"].as_str().unwrap(); + let line = receipt["summary"] + .as_str() + .unwrap() + .lines() + .find(|line| line.starts_with(&format!("{viewport}: gameplayPassed="))) + .unwrap(); + assert!(line.starts_with(&format!( + "{viewport}: gameplayPassed={}", + entry["result"]["passed"] + ))); + for field in [ + "passed", + "assertions", + "initialPhase", + "initialSequence", + "initialLevel", + "finalPhase", + "finalSequence", + "finalLevel", + ] { + assert_eq!(projected[field], entry["result"][field], "{field}"); + } + if let Some(reason) = entry["result"]["diagnostics"][0].as_str() { + assert_eq!(projected["diagnostics"][0], reason); + assert!(line.contains(reason)); + } + if let Some(assertion) = entry["result"]["assertions"] + .as_array() + .unwrap() + .iter() + .find(|assertion| assertion["passed"] == false) + { + assert!(line.contains(assertion["name"].as_str().unwrap())); + } + } result } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 794e50b73..8b7da7ad0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -458,7 +458,7 @@ pub(crate) async fn polish_local_project_prompt( pub(crate) fn cancel_direct_codex_turn( project_path: String, client_turn_id: Option, -) -> Result { +) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "agent.kill")?; cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) @@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset( file_name: String, media_type: String, bytes: Vec, + target_category: Option, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "asset.upload")?; let _lock = acquire_project_write_lock(root, "asset.upload")?; advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) + upload_local_asset_at_with_category( + root, + file_name.trim(), + media_type.trim(), + &bytes, + target_category.as_deref(), + ) } #[tauri::command] @@ -1084,7 +1091,7 @@ pub(crate) async fn read_direct_project_history_slice( }; let (raw_items, has_more, recorded_at_ms, first_item_id) = read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = thread_items_from_history(root, &raw_items, |item| { + let items = thread_items_from_history(&raw_items, |item| { thread_item_identity(item) .and_then(|identity| recorded_at_ms.get(&identity).copied()) .unwrap_or_default() diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 8fa08dcd6..018e9ff92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -311,6 +311,13 @@ async fn host_npm( fn browser_validation_failure_code(error: &str) -> &'static str { if error.contains("未发现可用的") { "web-preflight-browser-missing" + } else if error.starts_with("browser-recovery-") { + "web-preflight-browser-recovery-failed" + } else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid") + { + "web-preflight-browser-environment-failed" + } else if error.starts_with("browser-launch-failed:") { + "web-preflight-browser-launch-failed" } else if error.contains("启动浏览器超时") { "web-preflight-browser-launch-timeout" } else if error.contains("启动浏览器失败") { @@ -342,6 +349,13 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } +fn browser_validation_diagnostic(error: &str) -> Option<&str> { + (error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed:") + || error.starts_with("browser-cleanup-unconfirmed:")) + .then_some(error) +} + pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 @@ -362,18 +376,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value { let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput { url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"), }).await; - let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?; - if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); } + let result = validation + .map_err(|error| { + let code = browser_validation_failure_code(&error); + browser_validation_diagnostic(&error) + .map(|diagnostic| format!("{code};diagnostic={diagnostic}")) + .unwrap_or_else(|| code.to_string()) + })?; + if !result.passed || result.viewport_results.len() != 2 { + return Err("web-preflight-page-check-failed".into()); + } let mut pngs = Vec::new(); for viewport in result.viewport_results { - let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?; - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); } - image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?; - pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()})); + let bytes = fs::read(&viewport.screenshot_path) + .map_err(|_| "web-preflight-screenshot-missing")?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { + return Err("web-preflight-screenshot-invalid".into()); + } + image::load_from_memory(&bytes) + .map_err(|_| "web-preflight-screenshot-invalid")?; + pngs.push(json!({ + "viewport": viewport.viewport, + "passed": viewport.passed, + "pngBytes": bytes.len(), + })); } - Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}})) - }.await; - let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code})); + Ok::<_, String>(json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "ready", + "runtime": { + "source": runtime.source, + "nodeVersion": node, + "npmVersion": npm, + }, + "build": {"status": "ready", "kind": "npm-node-fixture"}, + "browser": {"status": "ready", "viewports": pngs}, + })) + } + .await; + let mut result = run.unwrap_or_else(|error| { + let (code, diagnostic) = error + .split_once(";diagnostic=") + .map_or((error.as_str(), None), |(code, diagnostic)| { + (code, Some(diagnostic)) + }); + let mut blocked = json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "blocked", + "code": code, + }); + if let Some(diagnostic) = diagnostic { + blocked["diagnostic"] = json!(diagnostic); + } + blocked + }); result["elapsedMs"] = json!(started.elapsed().as_millis()); result } @@ -382,9 +438,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value { pub(crate) async fn preflight_web_game_creation() -> Result { let result = host_web_creation_preflight().await; if result["status"] != "ready" { + let diagnostic = result["diagnostic"] + .as_str() + .map(|value| format!(";诊断:{value}")) + .unwrap_or_default(); return Err(format!( - "Web 游戏环境预检未通过:{};尚未启动生成", - result["code"].as_str().unwrap_or("web-preflight-failed") + "Web 游戏环境预检未通过:{}{};尚未启动生成", + result["code"].as_str().unwrap_or("web-preflight-failed"), + diagnostic, )); } Ok(result) @@ -548,16 +609,40 @@ mod tests { browser_validation_failure_code("启动浏览器失败:2"), "web-preflight-browser-launch-failed" ); + + assert_eq!( + browser_validation_failure_code( + "browser-launch-failed: stage=setup cause=browser-temp-unavailable" + ), + "web-preflight-browser-environment-failed" + ); + + let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true"; + assert_eq!( + browser_validation_failure_code(recovery_diagnostic), + "web-preflight-browser-recovery-failed" + ); + assert_eq!( + browser_validation_diagnostic(recovery_diagnostic), + Some(recovery_diagnostic) + ); + assert_eq!( + browser_validation_diagnostic("启动浏览器失败:原始错误"), + None + ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), "web-preflight-cancelled" ); + let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false"; assert_eq!( - browser_validation_failure_code( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程" - ), + browser_validation_failure_code(cleanup_diagnostic), "web-preflight-browser-cleanup-failed" ); + assert_eq!( + browser_validation_diagnostic(cleanup_diagnostic), + Some(cleanup_diagnostic) + ); assert_eq!( browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"), "web-preflight-browser-environment-failed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 6d086e49c..f8d02848c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -236,7 +236,42 @@ impl WindowsProcessJob { .ok_or_else(|| "子进程缺少 Windows process handle".to_string())?; Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE) } + pub(crate) fn contains_pid(&self, pid: u32) -> Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::IsProcessInJob; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err("无法打开进程核对 Windows Job 归属".into()); + } + let mut in_job = 0; + let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法核对进程 Windows Job 归属".into()); + } + Ok(in_job != 0) + } + pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; + if process.is_null() { + return Err("无法打开进程加入 Windows Job".into()); + } + let okay = unsafe { AssignProcessToJobObject(self.0, process) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法将进程加入 Windows Job".into()); + } + Ok(()) + } pub(crate) fn assign_tokio_named( child: &tokio::process::Child, name: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index fd03fa539..4aa2ec76e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -6350,15 +6350,17 @@ mod tests { "projectId": "source-binding-project" }]}}), ); - } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") - || request_line.starts_with("GET /api/editor/assets/library ") + } else if request_line.starts_with( + "GET /api/external/v1/editor/assets/folders/source-binding-folder ", + ) || request_line + .starts_with("GET /api/editor/assets/folders/source-binding-folder ") { write_json( &mut stream, "200 OK", - serde_json::json!({"data": {"library": {"folders": [{ + serde_json::json!({"data": {"folder": { "folderId": "source-binding-folder" - }]}}}), + }}}), ); } else if request_line .starts_with("POST /api/external/v1/assets/direct-upload-tickets ") @@ -7047,7 +7049,7 @@ mod tests { "generated/owner-b-source.{}", if is_image { "png" } else { "mp4" } ); - let request_count = if is_image { 6 } else { 7 }; + let request_count = 6; let server_upload_url = upload_url.clone(); let server_object_key = owner_b_object_key.clone(); let (sender, receiver) = mpsc::channel(); @@ -7082,18 +7084,12 @@ mod tests { serde_json::json!({"data": {"project": {"projectId": "owner-b-project"}}}), ); } else if request_line - .starts_with("GET /api/external/v1/editor/assets/library ") + .starts_with("GET /api/external/v1/editor/assets/folders/owner-b-folder ") { write_json( &mut stream, "200 OK", - if is_image { - serde_json::json!({"data": {"library": {"folders": [{ - "folderId": "owner-b-folder" - }]}}}) - } else { - serde_json::json!({"data": {"library": {"folders": []}}}) - }, + serde_json::json!({"data": {"folder": {"folderId": "owner-b-folder"}}}), ); } else if request_line .starts_with("POST /api/external/v1/editor/assets/folders ") @@ -7564,7 +7560,9 @@ mod tests { assert!(requests.iter().all(|request| { let line = request.lines().next().unwrap_or_default(); line.starts_with("GET /api/external/v1/editor/projects?view=summary ") - || line.starts_with("GET /api/external/v1/editor/assets/library ") + || line.starts_with( + "GET /api/external/v1/editor/assets/folders/source-binding-folder ", + ) })); } @@ -9743,7 +9741,7 @@ mod tests { let server_generated_video = generated_video.clone(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for request_index in 0..11 { + for request_index in 0..10 { let mut stream = accept_resource_editor_fixture_connection( &listener, "External video fixture", @@ -9769,12 +9767,6 @@ mod tests { "projectId": "external-video-project" }}}), ); - } else if request_line.starts_with("GET /api/external/v1/editor/assets/library ") { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"library": {"folders": []}}}), - ); } else if request_line.starts_with("POST /api/external/v1/editor/assets/folders ") { write_json( &mut stream, @@ -9892,7 +9884,7 @@ mod tests { .expect("derive External video"); server.join().expect("join External video server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 11); + assert_eq!(requests.len(), 10); assert!(requests.iter().all(|request| { !request.starts_with("POST /api/assets/") && !request.starts_with("GET /api/runtime/external-generation/") diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 870b7773c..c15fb2938 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -400,13 +400,15 @@ fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}), ); true - } else if line.starts_with("GET /api/external/v1/editor/assets/library ") - || line.starts_with("GET /api/editor/assets/library ") - { + } else if line.starts_with(&format!( + "GET /api/external/v1/editor/assets/folders/{REMOTE_FOLDER_ID} " + )) || line.starts_with(&format!( + "GET /api/editor/assets/folders/{REMOTE_FOLDER_ID} " + )) { write_json( stream, "200 OK", - serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}), + serde_json::json!({"data": {"folder": {"folderId": REMOTE_FOLDER_ID}}}), ); true } else { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 42c8e687f..96d8b5c54 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -49,6 +49,7 @@ import { commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, } from '../services/platformSession'; +import { expectNever } from './expectNever'; type ClientRuntimeErrorBoundaryProps = { children: ReactNode; @@ -73,14 +74,6 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; -/** - * 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。 - * 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。 - */ -function expectNever(value: never): void { - void value; -} - function withAuthCheckTimeout( promise: Promise, timeoutMs: number, diff --git a/apps/ai-game-creator-shell/src/app/expectNever.ts b/apps/ai-game-creator-shell/src/app/expectNever.ts new file mode 100644 index 000000000..ff92a6a0d --- /dev/null +++ b/apps/ai-game-creator-shell/src/app/expectNever.ts @@ -0,0 +1,10 @@ +/** + * 编译期穷尽检查:参数只能是 `never`,`switch` / `if` 链漏掉任何一个变体这一行就编译不过。 + * + * 运行时不做事(`void`),所以调用方的 `default` 分支仍需自己决定"漏网之鱼"怎么处理(通常 + * 是原样抛出交给既有捕获链路)。放在这里是因为它被认证命令与 Direct 命令两条链路共用,各自 + * 复制一份会在新增变体时漏掉其中一处。 + */ +export function expectNever(value: never): void { + void value; +} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index abe93c565..46318da72 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -665,7 +665,7 @@ export interface GameCreatorDirectToolCall { } /** `cancel_direct_codex_turn` 的返回值。 */ -export interface DirectTurnCancelView { +export interface TurnCancelView { /** * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index d50c6e060..7014d6605 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -509,7 +509,7 @@ export function projectRuntimeVisibleError( normalized.includes('落盘失败') || normalized.includes('持久化失败') || normalized.includes('写入失败') || - // 宿主"历史落盘"的事实句不带"失败"两个字:`TurnFailed` 的原因就是 + // 宿主"历史落盘"的事实句不带"失败"两个字:`SuperErrorFromStringPlusStage` 的原因就是 // "DirectProject 收尾历史失败:未确认历史完整落盘"这一类。 normalized.includes('收尾历史失败') || normalized.includes('未确认历史完整落盘') || diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 6da0d8280..149e5f7f7 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -42,7 +42,7 @@ import { createPortal } from 'react-dom'; import RichTextInput from '../../components/RichTextInput'; import { captureClientError } from '../../services/errorReporting'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { chatPromptDraftKey, readChatPromptPolishReminderDisabled, @@ -81,7 +81,7 @@ const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor'; type ResourceReferenceInputProps = { onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; - initialContent?: DirectCodexUserContentPart[]; + initialContent?: UserContentPart[]; /** * 宿主注入的引用来源,**按数组顺序取第一个非空回答**。 * @@ -153,7 +153,7 @@ class ReferenceMentionOption extends MenuOption { function collectDraftParts( node: LexicalNode, references: ChatReference[], - content: DirectCodexUserContentPart[], + content: UserContentPart[], ) { if ($isTextNode(node)) { const text = node.getTextContent(); @@ -184,13 +184,13 @@ function collectDraftParts( type DraftProjection = { references: ChatReference[]; - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const content: DirectCodexUserContentPart[] = []; + const content: UserContentPart[] = []; collectDraftParts($getRoot(), references, content); return { references: dedupeChatReferences(references), @@ -218,7 +218,7 @@ export function readResourceReferenceDraft( /** 第一个认得这个 part 的 provider 给出的引用;都不认得时返回 `null`。 */ function referenceFromPart( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): ChatReference | null { for (const provider of providers) { const reference = provider.toReference(part); @@ -230,7 +230,7 @@ function referenceFromPart( /** 第一个认得这个 part 的 provider 给出的正文 token;都不认得(例如文本 part)时返回 `null`。 */ function mentionTokenFromPart( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): string | null { for (const provider of providers) { const token = provider.mentionToken(part); @@ -248,7 +248,7 @@ function mentionTokenFromPart( */ function mentionTokenOrText( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): string | null { return mentionTokenFromPart(providers, part) ?? contentPartText(part); } @@ -261,7 +261,7 @@ function mentionTokenOrText( */ function promptTextFromContent( providers: readonly ReferenceProvider[], - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ): string { return joinMentionText(content, (part) => mentionTokenFromPart(providers, part), @@ -293,7 +293,7 @@ function applyPolishedTextToRoot( } function applyContentToRoot( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], providers: readonly ReferenceProvider[], ) { const root = $getRoot(); @@ -354,7 +354,7 @@ function $selectionOrRootEnd() { * 编辑器的默认粘贴,否则这段文字两边都不管。 */ function $insertContentAtSelection( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], providers: readonly ReferenceProvider[], ): boolean { // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。 @@ -493,12 +493,9 @@ function ResourceReferenceEditor({ [editor], ); - const applyContent = useCallback( - (content: readonly DirectCodexUserContentPart[]) => { - applyContentToRoot(content, providersRef.current); - }, - [], - ); + const applyContent = useCallback((content: readonly UserContentPart[]) => { + applyContentToRoot(content, providersRef.current); + }, []); useImperativeHandle( composerRef, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index a35ca1a4c..2f77b58a9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,5 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { readWorkspacePreferences, setChatPromptPolishReminderDisabled as setChatPromptPolishReminderPreference, @@ -49,9 +49,7 @@ export function subscribeChatPromptPolishReminderDisabled( } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ -export function chatPromptDraftKey( - content: readonly DirectCodexUserContentPart[], -) { +export function chatPromptDraftKey(content: readonly UserContentPart[]) { return JSON.stringify(content); } @@ -67,7 +65,7 @@ export function shouldRemindChatPromptPolish({ acknowledgedDraftKey, reminderDisabled, }: { - content: readonly DirectCodexUserContentPart[]; + content: readonly UserContentPart[]; prompt: string; acknowledgedDraftKey: string | null; reminderDisabled: boolean; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts index b0b9a0afb..27c0afc70 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { type ChatReference, normalizeMentionName, @@ -7,7 +7,7 @@ import type { ReferenceProvider } from './types'; /** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */ export function attachmentReferenceFromPart( - part: DirectCodexUserContentPart & { type: 'agc_attachment_reference' }, + part: UserContentPart & { type: 'agc_attachment_reference' }, ): ChatReference { return { type: 'attachment', @@ -28,7 +28,7 @@ export function attachmentReferenceFromPart( export function createAttachmentReferenceProvider(): ReferenceProvider { return { trigger: null, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => + toReference: (part: UserContentPart): ChatReference | null => part.type === 'agc_attachment_reference' ? attachmentReferenceFromPart(part) : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts index 7f724c365..1e03fde19 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts @@ -1,5 +1,5 @@ import type { GameCreationAppAssetManifestEntry } from '../../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { assetsSignature, type ChatReference, @@ -57,7 +57,7 @@ export function createResourceReferenceProvider({ .slice(0, MENTION_OPTION_LIMIT), // 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。 lookup: () => resourceProviderData(assets).references, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => { + toReference: (part: UserContentPart): ChatReference | null => { if (part.type !== 'agc_resource_reference') return null; const asset = resourceProviderData(assets).byId.get(part.resourceId); // 资产已不在 manifest(已删除):这里不合成引用,由调用方按同一份清单显式报缺口, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts index 1a662443a..080e3eb9d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import type { ChatReference, RuntimeRegionReference, @@ -14,7 +14,7 @@ import type { ReferenceProvider } from './types'; export function createRuntimeRegionReferenceProvider(): ReferenceProvider { return { trigger: null, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => { + toReference: (part: UserContentPart): ChatReference | null => { if (part.type !== 'agc_runtime_region_reference') return null; return { type: 'runtime-region', diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index e99648c42..a26721340 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../app/tauri'; -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { type ChatReference, normalizeMentionName, @@ -128,7 +128,7 @@ export function useSkillReferenceProvider(): ReferenceProvider { ) .slice(0, MENTION_OPTION_LIMIT); }, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => + toReference: (part: UserContentPart): ChatReference | null => part.type === 'agc_skill_reference' ? { type: 'skill', name: normalizeMentionName(part.name) } : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts index bf78a4901..e65c04b1c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import type { ChatReference } from '../resourceReferences'; /** @@ -47,11 +47,11 @@ export type ReferenceProvider = { */ onMenuQueryChange?: (query: string | null) => void; /** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */ - toReference: (part: DirectCodexUserContentPart) => ChatReference | null; + toReference: (part: UserContentPart) => ChatReference | null; /** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */ refresh: (reference: ChatReference) => ChatReference | null; /** part 在正文文本里的 token(`@显示名` / `$名称`);不属于本 provider 时返回 `null`。 */ - mentionToken: (part: DirectCodexUserContentPart) => string | null; + mentionToken: (part: UserContentPart) => string | null; /** * 数据是否已经到齐(例如 manifest 还没加载完时为 `false`);省略表示始终就绪。 * diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index adc78ecba..bab8718ff 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -12,9 +12,9 @@ import { buildGameCreationAppAssetTagLibrary, type GameCreationAppAssetTagLibraryEntry, } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; -import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem'; +import type { UserAttachmentReferencePart } from '../../view/project-development/chat/generated/UserAttachmentReferencePart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; +import type { UserItem } from '../../view/project-development/chat/generated/UserItem'; export type ResourceReferenceSource = | 'asset-picker' @@ -61,7 +61,7 @@ export type SkillReference = { */ export type AttachmentReference = { type: 'attachment'; -} & DirectCodexUserAttachmentReferencePart; +} & UserAttachmentReferencePart; export type ChatReference = | ResourceReference @@ -71,7 +71,7 @@ export type ChatReference = export type ChatComposerDraft = { /** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; /** 仅供仍未迁移的旧调用方消费的单向投影,不得回传给 ResourceReferenceInput。 */ @@ -183,8 +183,8 @@ export function normalizeMentionName(value: string) { * (`hasMeaningfulDirectCodexContent`)算「空」;需要首尾裁剪的调用方在自己的边界做。 */ export function joinMentionText( - content: readonly DirectCodexUserContentPart[], - tokenOf: (part: DirectCodexUserContentPart) => string | null, + content: readonly UserContentPart[], + tokenOf: (part: UserContentPart) => string | null, ): string { let text = ''; // 上一个 token 的后置空白尚未落地:下一个 part 决定它是被吃掉(本来就以空白开头) @@ -224,7 +224,7 @@ export function joinMentionText( * 这里的 token 拼接只是当前口径的落点。) */ export function directCodexContentToPromptText( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], resolveResourceLabel: ResourceLabelResolver, ) { return joinMentionText(content, (part) => @@ -240,7 +240,7 @@ export function directCodexContentToPromptText( * 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。 */ function contentPartToken( - part: DirectCodexUserContentPart, + part: UserContentPart, resolveResourceLabel: ResourceLabelResolver, ): string | null { if (part.type === 'input_text') return null; @@ -261,15 +261,13 @@ function contentPartToken( * 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去, * 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。 */ -export function contentPartText( - part: DirectCodexUserContentPart, -): string | null { +export function contentPartText(part: UserContentPart): string | null { return contentPartToken(part, () => undefined); } /** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */ export function hasMeaningfulDirectCodexContent( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ) { return content.some( (part) => part.type !== 'input_text' || part.text.trim().length > 0, @@ -284,9 +282,9 @@ export function hasMeaningfulDirectCodexContent( * 无关化。逐字保留空白,不做裁剪。 */ export function directCodexContentKey( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ): string { - const normalized: DirectCodexUserContentPart[] = []; + const normalized: UserContentPart[] = []; content.forEach((part) => { if (part.type !== 'input_text') { normalized.push(part); @@ -305,7 +303,7 @@ export function directCodexContentKey( export function chatReferenceToContentPart( reference: ChatReference, -): DirectCodexUserContentPart { +): UserContentPart { if (reference.type === 'resource') { return { type: 'agc_resource_reference', resourceId: reference.resourceId }; } @@ -365,12 +363,12 @@ export function chatReferenceDisplayHint(reference: ChatReference): string { /** 一条待恢复 part 与它在文本里的 token。 */ export type ContentTokenCandidate = { token: string; - part: DirectCodexUserContentPart; + part: UserContentPart; }; type ContentTokenSegment = | { kind: 'text'; text: string } - | { kind: 'part'; part: DirectCodexUserContentPart }; + | { kind: 'part'; part: UserContentPart }; function isMentionTokenBoundary(character: string | undefined) { return character === undefined || character === '' || /\s/u.test(character); @@ -408,7 +406,7 @@ function findMentionToken(line: string, token: string, from: number) { export function buildContentFromTextTokens( value: string, candidates: readonly ContentTokenCandidate[], -): DirectCodexUserContentPart[] { +): UserContentPart[] { const pending = candidates.map((candidate) => ({ ...candidate, used: false, @@ -454,7 +452,7 @@ export function buildContentFromTextTokens( lastLine.push(...orphans); } } - const content: DirectCodexUserContentPart[] = []; + const content: UserContentPart[] = []; lineSegments.forEach((segments, lineIndex) => { segments.forEach((segment) => { if (segment.kind === 'text') { @@ -509,7 +507,7 @@ function countMentionTokenOccurrences(value: string, token: string) { export function buildContentFromPastedText( value: string, references: readonly ChatReference[], -): DirectCodexUserContentPart[] | null { +): UserContentPart[] | null { const referencesByToken = new Map(); for (const reference of references) { const token = chatReferenceMentionToken(reference); @@ -552,7 +550,7 @@ export function buildContentFromPastedText( export function legacyContentDtoToContent(dto: { text: string; references: readonly ChatReference[]; -}): DirectCodexUserContentPart[] { +}): UserContentPart[] { return buildContentFromTextTokens(dto.text, [ ...dto.references.map((reference) => ({ token: chatReferenceMentionToken(reference), @@ -561,23 +559,23 @@ export function legacyContentDtoToContent(dto: { ]); } -export function chatComposerDraftToDirectCodexUserItem( +export function chatComposerDraftToUserItem( draft: ChatComposerDraft, id: string, -): DirectCodexUserItem { +): UserItem { return { type: 'message', role: 'user', content: draft.content, id, - } satisfies DirectCodexUserItem; + } satisfies UserItem; } -export function directCodexUserItemFromContent( - content: readonly DirectCodexUserContentPart[], +export function userItemFromContent( + content: readonly UserContentPart[], id: string, -): DirectCodexUserItem { - return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id); +): UserItem { + return chatComposerDraftToUserItem({ content: [...content] }, id); } /** @@ -609,7 +607,7 @@ function unresolvedResourceReference(resourceId: string): ResourceReference { * 仍然收 `assets`,只是把显示名展开交给 `resourceLabelResolver` 这一份注入的口径。 */ export function directCodexContentToLegacyContentDto( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], assets: readonly GameCreationAppAssetManifestEntry[], ): DirectCodexLegacyContentDto { const references: ChatReference[] = []; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 70f91e38a..bd371362b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -20,7 +20,7 @@ import type { import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/image-editor/editorProjectClient'; import { ThemedModal } from '../../components/modal/ThemedModal'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; import { attachmentReferenceProvider } from '../project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../project-workspace/reference-source/resourceReferenceProvider'; @@ -65,7 +65,7 @@ export type ResourceCanvasAssetGenerationSubmitInput = { * 面板**不**在这里拆出 `prompt` / `references` 双轨——宿主在任务落账那一个出站边界上才用 * `directCodexContentToLegacyContentDto` 翻译出账本要的文本与参考身份。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; @@ -79,7 +79,7 @@ export type ResourceCanvasAssetGenerationSubmitInput = { * `prompt + references` 的 DTO。 */ export type ResourceCanvasAssetGenerationPanelDraft = { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; @@ -185,7 +185,7 @@ export function ResourceCanvasAssetGenerationPanelView({ * 都是它;提示词文本与引用列表只是渲染期的单向投影(下面的 * `directCodexContentToLegacyContentDto`),用来做字数、计数与失效参考的判据。 */ - const [content, setContent] = useState(() => + const [content, setContent] = useState(() => draft ? [...draft.content] : [], ); /** @@ -215,7 +215,7 @@ export function ResourceCanvasAssetGenerationPanelView({ * 取挂载那一帧的 `draft`:宿主重建重试草稿时用的就是账本里原始请求的输入。 */ const boundRequestRef = useRef<{ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts index 429d83a34..9b848ccbc 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasBottomToolbarModel.ts @@ -99,7 +99,13 @@ export type ResourceCanvasAudioToolAction = audioKind: ResourceCanvasGenerationKind; }; -/** 上传入口:复用既有 `upload_local_asset`。 */ +/** + * 上传入口:复用既有 `upload_local_asset`。 + * + * 调用方按当前栏目带 `targetCategory`(见 `uploadResourceCanvasToolbarFiles`)——上传的 + * manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都会派生成 `unclassified`, + * 不带入口栏目素材就落「待归类」、在上传它的那一栏里看不见。 + */ export type ResourceCanvasUploadToolAction = ResourceCanvasBottomToolActionBase & { route: 'upload'; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts index 73188e2d3..c66564189 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthClientInitFailed.ts @@ -3,4 +3,4 @@ /** * 认证网络客户端构建失败的原始错误明细。 */ -export type AuthClientInitFailed = { detail: string }; +export type AuthClientInitFailed = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts index a852d6759..0715674b6 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkFailure.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthNetworkReason } from './AuthNetworkReason'; +import type { AuthNetworkReason } from "./AuthNetworkReason"; /** * 连接登录服务的传输层失败原因。 */ -export type AuthNetworkFailure = { reason: AuthNetworkReason }; +export type AuthNetworkFailure = { reason: AuthNetworkReason, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts index 73f2a73f9..193ee7a73 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthNetworkReason.ts @@ -3,4 +3,4 @@ /** * 连接登录服务失败的具体原因。 */ -export type AuthNetworkReason = 'timeout' | 'unreachable'; +export type AuthNetworkReason = "timeout" | "unreachable"; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts index b9b57f307..0175ed188 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalid.ts @@ -1,13 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthResponseInvalidReason } from './AuthResponseInvalidReason'; +import type { AuthResponseInvalidReason } from "./AuthResponseInvalidReason"; /** * 登录响应契约破损的原因与服务端原文。 */ -export type AuthResponseInvalid = { - reason: AuthResponseInvalidReason; - /** - * 只有 `serverRejected` 可能带服务端原文;其余是 `null`。 - */ - serverMessage: string | null; -}; +export type AuthResponseInvalid = { reason: AuthResponseInvalidReason, +/** + * 只有 `serverRejected` 可能带服务端原文;其余是 `null`。 + */ +serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts index cd7dd488f..57887317f 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthResponseInvalidReason.ts @@ -3,9 +3,4 @@ /** * 登录响应契约破损的具体原因。 */ -export type AuthResponseInvalidReason = - | 'notJson' - | 'invalidBody' - | 'missingRefreshCookie' - | 'missingUserIdentity' - | 'serverRejected'; +export type AuthResponseInvalidReason = "notJson" | "invalidBody" | "missingRefreshCookie" | "missingUserIdentity" | "serverRejected"; diff --git a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts index 600fdcc93..4bd75d065 100644 --- a/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts +++ b/apps/ai-game-creator-shell/src/services/generated/AuthServiceUnavailable.ts @@ -3,4 +3,4 @@ /** * 登录服务 5xx 的状态码。 */ -export type AuthServiceUnavailable = { status: number }; +export type AuthServiceUnavailable = { status: number, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts index 9e87dc86d..994aefb32 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientAuthError.ts @@ -1,35 +1,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthClientInitFailed } from './AuthClientInitFailed'; -import type { AuthNetworkFailure } from './AuthNetworkFailure'; -import type { AuthResponseInvalid } from './AuthResponseInvalid'; -import type { AuthServiceUnavailable } from './AuthServiceUnavailable'; -import type { ClientSessionPersistFailed } from './ClientSessionPersistFailed'; -import type { PasswordLoginRejected } from './PasswordLoginRejected'; -import type { PhoneCodeLoginRejected } from './PhoneCodeLoginRejected'; -import type { RuntimeSessionInstallFailed } from './RuntimeSessionInstallFailed'; -import type { SendCodeRejected } from './SendCodeRejected'; -import type { ServerAddressRejected } from './ServerAddressRejected'; -import type { UnexpectedRejection } from './UnexpectedRejection'; +import type { AuthClientInitFailed } from "./AuthClientInitFailed"; +import type { AuthNetworkFailure } from "./AuthNetworkFailure"; +import type { AuthResponseInvalid } from "./AuthResponseInvalid"; +import type { AuthServiceUnavailable } from "./AuthServiceUnavailable"; +import type { ClientSessionPersistFailed } from "./ClientSessionPersistFailed"; +import type { PasswordLoginRejected } from "./PasswordLoginRejected"; +import type { PhoneCodeLoginRejected } from "./PhoneCodeLoginRejected"; +import type { RuntimeSessionInstallFailed } from "./RuntimeSessionInstallFailed"; +import type { SendCodeRejected } from "./SendCodeRejected"; +import type { ServerAddressRejected } from "./ServerAddressRejected"; +import type { UnexpectedRejection } from "./UnexpectedRejection"; /** * 变体名就是线上的分流键(`type`),带载荷的变体持有同名载荷类型。 */ -export type ClientAuthError = - | ({ type: 'serverAddressRejected' } & ServerAddressRejected) - | { type: 'phoneNumberInvalid' } - | { type: 'passwordMissing' } - | { type: 'loginCodeMissing' } - | ({ type: 'passwordLoginRejected' } & PasswordLoginRejected) - | { type: 'phoneOrPasswordMismatch' } - | ({ type: 'sendCodeRejected' } & SendCodeRejected) - | { type: 'smsCodeThrottled' } - | ({ type: 'phoneCodeLoginRejected' } & PhoneCodeLoginRejected) - | { type: 'sessionInvalidated' } - | { type: 'permissionDenied' } - | ({ type: 'authNetworkFailure' } & AuthNetworkFailure) - | ({ type: 'authServiceUnavailable' } & AuthServiceUnavailable) - | ({ type: 'unexpectedRejection' } & UnexpectedRejection) - | ({ type: 'authResponseInvalid' } & AuthResponseInvalid) - | ({ type: 'clientSessionPersistFailed' } & ClientSessionPersistFailed) - | ({ type: 'runtimeSessionInstallFailed' } & RuntimeSessionInstallFailed) - | ({ type: 'authClientInitFailed' } & AuthClientInitFailed); +export type ClientAuthError = { "type": "serverAddressRejected" } & ServerAddressRejected | { "type": "phoneNumberInvalid" } | { "type": "passwordMissing" } | { "type": "loginCodeMissing" } | { "type": "passwordLoginRejected" } & PasswordLoginRejected | { "type": "phoneOrPasswordMismatch" } | { "type": "sendCodeRejected" } & SendCodeRejected | { "type": "smsCodeThrottled" } | { "type": "phoneCodeLoginRejected" } & PhoneCodeLoginRejected | { "type": "sessionInvalidated" } | { "type": "permissionDenied" } | { "type": "authNetworkFailure" } & AuthNetworkFailure | { "type": "authServiceUnavailable" } & AuthServiceUnavailable | { "type": "unexpectedRejection" } & UnexpectedRejection | { "type": "authResponseInvalid" } & AuthResponseInvalid | { "type": "clientSessionPersistFailed" } & ClientSessionPersistFailed | { "type": "runtimeSessionInstallFailed" } & RuntimeSessionInstallFailed | { "type": "authClientInitFailed" } & AuthClientInitFailed; diff --git a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts index 258f43fb0..b6f047440 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ClientSessionPersistFailed.ts @@ -3,4 +3,4 @@ /** * 本机凭据文件读写的原始错误明细。 */ -export type ClientSessionPersistFailed = { detail: string }; +export type ClientSessionPersistFailed = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts index 450754f46..2296163ae 100644 --- a/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/PasswordLoginRejected.ts @@ -3,9 +3,8 @@ /** * `/api/auth/entry` 返回 400 时服务端给的原文。 */ -export type PasswordLoginRejected = { - /** - * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 - */ - serverMessage: string | null; -}; +export type PasswordLoginRejected = { +/** + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + */ +serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts index 403eaebbd..51ae18c75 100644 --- a/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/PhoneCodeLoginRejected.ts @@ -3,9 +3,8 @@ /** * `/api/auth/phone/login` 返回 400 时服务端给的原文。 */ -export type PhoneCodeLoginRejected = { - /** - * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 - */ - serverMessage: string | null; -}; +export type PhoneCodeLoginRejected = { +/** + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + */ +serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts index df2940b5d..98343be13 100644 --- a/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts +++ b/apps/ai-game-creator-shell/src/services/generated/RuntimeSessionInstallFailed.ts @@ -3,4 +3,4 @@ /** * 本机运行时会话安装 / 清理的原始错误明细。 */ -export type RuntimeSessionInstallFailed = { detail: string }; +export type RuntimeSessionInstallFailed = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts index cdf2a07c0..54f8bd8df 100644 --- a/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/SendCodeRejected.ts @@ -3,9 +3,8 @@ /** * `/api/auth/phone/send-code` 返回 400 时服务端给的原文。 */ -export type SendCodeRejected = { - /** - * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 - */ - serverMessage: string | null; -}; +export type SendCodeRejected = { +/** + * 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + */ +serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts index c60ebb82b..85a950d40 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressReason.ts @@ -3,11 +3,4 @@ /** * 服务地址校验失败的具体原因。 */ -export type ServerAddressReason = - | 'emptyOrTooLong' - | 'notAUrl' - | 'hasCredentials' - | 'hasPathOrQueryOrFragment' - | 'notHttps' - | 'unsupportedScheme' - | 'outsideChannel'; +export type ServerAddressReason = "emptyOrTooLong" | "notAUrl" | "hasCredentials" | "hasPathOrQueryOrFragment" | "notHttps" | "unsupportedScheme" | "outsideChannel"; diff --git a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts index fdcc96aa0..94ac0c9bb 100644 --- a/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts +++ b/apps/ai-game-creator-shell/src/services/generated/ServerAddressRejected.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ServerAddressReason } from './ServerAddressReason'; +import type { ServerAddressReason } from "./ServerAddressReason"; /** * 服务地址被拒的具体原因。 */ -export type ServerAddressRejected = { reason: ServerAddressReason }; +export type ServerAddressRejected = { reason: ServerAddressReason, }; diff --git a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts index b2c4041eb..6e3d743db 100644 --- a/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts +++ b/apps/ai-game-creator-shell/src/services/generated/UnexpectedRejection.ts @@ -3,7 +3,4 @@ /** * 其它未识别拒绝的状态码与服务端原文。 */ -export type UnexpectedRejection = { - status: number; - serverMessage: string | null; -}; +export type UnexpectedRejection = { status: number, serverMessage: string | null, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index 86a7b0ef3..f2d345d75 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -10,7 +10,7 @@ import { import { claimInitialTurnForPage } from '../../../app/initialTurnClaims'; import type { PendingUiConfirmation } from '../../../app/types'; import { projectNameFromPath } from '../../../features/agent-runtime'; -import { directCodexUserItemFromContent } from '../../../features/project-workspace/resourceReferences'; +import { userItemFromContent } from '../../../features/project-workspace/resourceReferences'; import { type ApprovalMode, approvalModeLabel } from '../approvalMode'; import { ApprovalModeDialog } from '../ApprovalModeDialog'; import { DirectProjectChatHeader } from './components/DirectProjectChatHeader/DirectProjectChatHeader'; @@ -30,7 +30,7 @@ import { type DirectProjectTurnInput, } from './conversation/directCodexConversation'; import { buildDirectChatTurns } from './conversation/directTurnPresentation'; -import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart'; +import type { UserContentPart } from './generated/UserContentPart'; /** * DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后, @@ -53,7 +53,7 @@ export type DirectProjectInitialTurn = { * 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content * 里,和 Composer 提交的回合保持同一种形状。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; creationType?: DirectProjectTurnInput['creationType']; claimScope: string; }; @@ -191,7 +191,7 @@ export function DirectProjectChatView({ return; } const clientTurnId = createDirectProjectTurnId(); - const userItem = directCodexUserItemFromContent( + const userItem = userItemFromContent( initialTurn.content, directCodexConversationMessageId(clientTurnId, 'user'), ); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index f151e8344..4092a9939 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -24,7 +24,7 @@ import { import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; -import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../generated/UserContentPart'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -34,9 +34,7 @@ import { } from './ComposerControls'; /** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */ -function draftAttachmentCount(draft: { - content: DirectCodexUserContentPart[]; -}) { +function draftAttachmentCount(draft: { content: UserContentPart[] }) { return draft.content.filter( (part) => part.type === 'agc_attachment_reference', ).length; @@ -100,7 +98,7 @@ export function DirectProjectComposer({ * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 * 界面上凭空消失。 */ - onSubmit: (content: DirectCodexUserContentPart[]) => Promise; + onSubmit: (content: UserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 6a4655995..46705025a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -7,14 +7,14 @@ import { import { resolveTauriInvoke } from '../../../../app/tauri'; import type { ChatMessage, - DirectTurnCancelView, TauriInvoke, + TurnCancelView, } from '../../../../app/types'; import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; import { - directCodexUserItemFromContent, hasMeaningfulDirectCodexContent, + userItemFromContent, } from '../../../../features/project-workspace/resourceReferences'; import { captureAgentRuntimeError } from '../../../../services/errorReporting'; import { @@ -23,19 +23,22 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - directTurnEnqueueFailureNotice, - directTurnEnqueueFailureNoticeMessageId, - directTurnUnrecognizedEnqueueFailureNoticeText, - readDirectTurnEnqueueFailure, } from '../conversation/directCodexConversation'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, } from '../conversation/directCodexTurnAttachments'; -import type { DirectCodexUserContentPart } from '../generated/DirectCodexUserContentPart'; +import { + enqueueFailureNotice, + enqueueFailureNoticeMessageId, + EnqueueFailureWrapper, + enqueueSystemFailureNoticeText, + invokeTurnEnqueue, +} from '../conversation/directTurnEnqueueFailure'; import type { HistorySlice } from '../generated/HistorySlice'; import type { QueueRemovalOutcome } from '../generated/QueueRemovalOutcome'; import type { ThreadItem } from '../generated/ThreadItem'; +import type { UserContentPart } from '../generated/UserContentPart'; import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate'; import { readDirectHistoryPages } from '../history/directHistoryPaging'; import { canStopDirectProjectTurn } from './useDirectProjectTurnStatus'; @@ -420,12 +423,10 @@ export function useDirectProjectChatController({ * 本文件不再有"回合运行中就先排队"的分支:队列在宿主侧,忙不忙都由宿主自己判(队满、重复入队 * 也由它回答)。返回值就是"草稿能不能清":入队失败里用户自己能改的那些返回 `false`,草稿留下。 */ - async function submit( - content: DirectCodexUserContentPart[], - ): Promise { + async function submit(content: UserContentPart[]): Promise { if (!hasMeaningfulDirectCodexContent(content)) return false; const clientTurnId = createDirectProjectTurnId(); - const userItem = directCodexUserItemFromContent( + const userItem = userItemFromContent( content, directCodexConversationMessageId(clientTurnId, 'user'), ); @@ -514,69 +515,71 @@ export function useDirectProjectChatController({ // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 let draftSafe = false; try { - await invoke('enqueue_direct_codex_turn', { + await invokeTurnEnqueue(invoke, { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, userItem: input.userItem, - ...(input.creationType ? { creationType: input.creationType } : {}), + creationType: input.creationType, }); draftSafe = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 // 只剩"入队失败"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 - const rejection = readDirectTurnEnqueueFailure(error); - if (rejection) { - const notice = directTurnEnqueueFailureNotice(rejection); - if (notice) { - // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 - // 也不上报(用户自己就能改,上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条,界面上 - // 那条同级提示说的是"为什么",用户改一改就能直接再按发送。 - if (projectPathRef.current === nextProjectPath) { - onRuntimeError(''); - appendLocalMessage({ - role: 'assistant', - text: notice, - runtimeOwned: true, - messageId: directTurnEnqueueFailureNoticeMessageId( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ), - updatedAt: Date.now(), - }); - } - return false; + // 分界只看 JS 载体(`instanceof`),不嗅探对象形状。 + const failure = + error instanceof EnqueueFailureWrapper ? error.error : null; + const notice = failure ? enqueueFailureNotice(failure) : null; + if (failure && notice) { + // 用户自己就能改的业务变体(前置条件 / 参数 / 队列已满 / 权限策略):写成与用户消息同级的提示, + // 不占状态行、不写运行错误、也不上报(上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条, + // 提示说的是"为什么",用户改一改就能直接再按发送。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(''); + appendLocalMessage({ + role: 'assistant', + text: notice, + runtimeOwned: true, + messageId: enqueueFailureNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); } + return false; } + // 宿主 / 环境事实变体的固定句;认不出形状(Tauri / 宿主缺陷、未来新变体)时没有这句话。 + const systemNotice = failure && enqueueSystemFailureNoticeText(failure); if (projectPathRef.current !== nextProjectPath) return true; - // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 + // 认不出的入队失败与其它非结构化错误走同一条通道:上报 + 横幅,**不写聊天**(说不清原因, + // 也不该把宿主原文回落给用户)。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; - // 入队失败不带阶段标签(这一轮没有开始),所以走入队失败那一份映射。 - const rawMessage = error instanceof Error ? error.message : String(error); - const visibleMessage = rejection - ? directTurnUnrecognizedEnqueueFailureNoticeText(rejection) - : projectRuntimeVisibleError(rawMessage, '陶泥儿智能创作', true); - // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 + if (!systemNotice) { + onRuntimeError( + projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ), + ); + return true; + } + // 这类失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 // 用户以为没发出去、再按一次。 draftSafe = true; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 - // 非结构化错误同样不写聊天:它可能发生在放行之后,说明由事件流负责。 - if (rejection) { - appendLocalMessage({ - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - messageId: directTurnEnqueueFailureNoticeMessageId( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ), - updatedAt: Date.now(), - }); - } - // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 - // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 - onRuntimeError(visibleMessage); + appendLocalMessage({ + role: 'assistant', + text: systemNotice, + runtimeOwned: true, + messageId: enqueueFailureNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + onRuntimeError(systemNotice); } return draftSafe; } @@ -608,12 +611,9 @@ export function useDirectProjectChatController({ setTurnCancelling(true); setComposerNotice('正在终止当前回合'); try { - const result = await invoke( - 'cancel_direct_codex_turn', - { - projectPath, - }, - ); + const result = await invoke('cancel_direct_codex_turn', { + projectPath, + }); const message = result?.message?.trim(); if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts index 0f6d6dcee..1385de7d4 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectThreadChatSubscription.ts @@ -20,6 +20,10 @@ import { resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../conversation/directThreadChat'; +import { + sanitizeDirectThreadEvents, + sanitizeDirectThreadItems, +} from '../conversation/directThreadSanitize'; import type { ConsumeResult } from '../generated/ConsumeResult'; import type { SubscriptionBootstrap } from '../generated/SubscriptionBootstrap'; import type { ThreadItem } from '../generated/ThreadItem'; @@ -117,7 +121,10 @@ export function useDirectThreadChatSubscription({ ); if (disposed) return; setState((current) => - applyDirectThreadConsumeResult(current, result), + applyDirectThreadConsumeResult(current, { + ...result, + events: sanitizeDirectThreadEvents(projectPath, result.events), + }), ); } while (consumeAgain && !disposed); } catch (error) { @@ -151,7 +158,12 @@ export function useDirectThreadChatSubscription({ } subscriptionId = result.subscriptionId; anchorGate.settle(result.lastCompletedItemId ?? null); - setState((current) => resolveDirectThreadBootstrap(current, result)); + setState((current) => + resolveDirectThreadBootstrap(current, { + ...result, + events: sanitizeDirectThreadEvents(projectPath, result.events), + }), + ); if (notifyBeforeSubscription) { notifyBeforeSubscription = false; void consume(); @@ -190,9 +202,14 @@ export function useDirectThreadChatSubscription({ const mergeHistoryItems = useMemo( () => (items: readonly ThreadItem[]) => { - setState((current) => mergeDirectHistoryItems(current, items)); + setState((current) => + mergeDirectHistoryItems( + current, + sanitizeDirectThreadItems(projectPath, items), + ), + ); }, - [], + [projectPath], ); const entries = useMemo(() => selectDirectChatEntries(state), [state]); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index bb1231450..40b95d28a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,8 +1,6 @@ import type { ChatMessage } from '../../../../app/types'; -import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; -import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; -import type { DirectTurnEnqueueFailure } from '../generated/DirectTurnEnqueueFailure'; +import type { UserItem } from '../generated/UserItem'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; @@ -20,7 +18,7 @@ export type DirectProjectTurnInput = { * 这一轮的 canonical 用户条目:文本、`@` 素材引用、技能引用和附件引用都在同一份 * content 里内联,附件不再作为并排字段单独传递。 */ - userItem: DirectCodexUserItem; + userItem: UserItem; /** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */ directPolicyChecked?: boolean; }; @@ -61,80 +59,3 @@ export function directCodexConversationMessageId( ) { return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } - -/** - * 命令的**入队失败**载荷(Rust 侧 `DirectTurnEnqueueFailure`):结构化变体 + 宿主生成的文案。 - * - * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 - * `error.type`——文案是给人看的,不参与任何判断。 - */ -export function readDirectTurnEnqueueFailure( - error: unknown, -): DirectTurnEnqueueFailure | null { - if (!error || typeof error !== 'object') return null; - const candidate = error as { error?: unknown; message?: unknown }; - const variant = candidate.error; - if (!variant || typeof variant !== 'object') return null; - const type = (variant as { type?: unknown }).type; - if (typeof type !== 'string' || !type) return null; - if (typeof candidate.message !== 'string') return null; - return candidate as DirectTurnEnqueueFailure; -} - -/** - * 认得的入队失败(前置条件不满足 / 用户参数无效 / 队列已满)→ 与用户消息同级的提示文案; - * 认不得的返回 `null`,由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 - * - * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 - * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 - * - * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / - * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 - * - * 空文案按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`——宿主给不出可展示的文案 - * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 - * 调用方当成"有提示")。 - */ -export function directTurnEnqueueFailureNotice( - failure: DirectTurnEnqueueFailure, -): string | null { - switch (failure.error.type) { - case 'clientTurnIdMissing': - case 'clientTurnIdMalformed': - case 'queueFull': - case 'projectRootUnanchored': - case 'projectRootUnusable': - case 'permissionRejected': - case 'inputRejected': - case 'contentEmpty': - return failure.message.trim() || null; - default: - return null; - } -} - -/** - * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, - * 两条通道永远不会合并成一条。 - */ -export function directTurnEnqueueFailureNoticeMessageId(userItemId: string) { - return `${userItemId}:rejected`; -} - -/** - * **认不出的**入队失败(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 - * - * 这两类失败不产生 `turn.completed`(入队失败的这一轮从未成立),而本地也不再造用户气泡,所以 - * 这一轮在聊天区里本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。 - * 它带自己的身份(`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 - * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 - * - * 文案不能原样用宿主给的 `message`:这类失败的 `message` 是宿主的收口文案(带 `stage=` / `code=` - * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, - * 绝不把内部字段塞进聊天。 - */ -export function directTurnUnrecognizedEnqueueFailureNoticeText( - failure: DirectTurnEnqueueFailure, -): string { - return projectRuntimeVisibleRejectionError(failure.message, '陶泥儿智能创作'); -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts index 72fa4dfad..30e9bde82 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts @@ -8,14 +8,14 @@ * * 列表顺序就是事件的到达顺序(FIFO,宿主侧的认领也认队首),所以这里没有排序、没有身份表。 */ -import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; +import type { UserItem } from '../generated/UserItem'; /** 一条还没放行的待发消息。 */ export type DirectPendingTurn = { /** 这条待发消息的回合身份;放行之后同一轮的 `turn.started` / `turn.completed` 用它。 */ clientTurnId: string; /** canonical 用户条目:chip 文案由它派生(与聊天正文同一个派生)。 */ - userItem: DirectCodexUserItem; + userItem: UserItem; /** 入队那一刻的宿主毫秒钟。 */ at: number; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 1e799e3d3..1bb0c20d9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -366,21 +366,18 @@ export function reduceDirectThreadEvent( return state; } // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 - // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 + // 函数。载荷在才算一条说明(文案由 typed 变体拼,必非空);缺载荷不补一条空气泡。 const failure = event.failure; - // `message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验:缺字段 / `null` - // 时直接 `.trim()` 会在 reducer 里抛错,把这一条订阅之后的全部事件一起打断。判据与兄弟 - // 函数 `directTurnFailureNoticeText` 保持一致,都是"不是非空字符串就当没有原因"。 - const failureText = - failure && typeof failure.message === 'string' - ? failure.message.trim() - : ''; + // 载荷在生成类型里是必填的,但跨 IPC 没有运行时校验:缺字段 / `null` 时当"没有原因"处理, + // 不在这里抛错——一条坏事件不该把这一条订阅之后的全部事件一起打断。文案只从 typed 变体拼, + // 宿主原文(`detail` / `cause` / `diagnostic`)不上屏。 + const failureText = failure ? directTurnFailureNoticeText(failure) : ''; const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt); const noticeOf = (): DirectChatEntry => ({ itemId: noticeItemId, kind: 'message', role: 'assistant', - text: directTurnFailureNoticeText(failureText), + text: failureText, at: eventAt, // 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、 // 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts new file mode 100644 index 000000000..5a8780be6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadSanitize.ts @@ -0,0 +1,613 @@ +/** + * DirectProject 线程条目的前端脱敏。 + * + * Thread Manager 现在把宿主文本**原样**下发(不脱敏、不限长),密钥、绝对路径、凭据配置等敏感 + * 片段在这里统一遮挡:运行态事件、流式增量与历史切片在进入聊天状态之前先过一次本模块。 + * + * 为什么放在前端:脱敏是"给谁看"的问题,不是"搬运"的问题——Thread Manager 只归一身形与身份, + * 由离展示最近、手里又有 `projectPath` 的这一层决定什么能上屏。失败载荷(`turn.completed.failure` + * 的错误文案)的脱敏仍由 Rust 在投影时完成,不在这里重复。 + * + * 只遮挡、不截断:正文与明细都保持原文长度。 + * + * 占位符沿用宿主原来的词表,`features/agent-runtime/model.ts` 的中文显示映射不用改: + * `` / `[redacted-secret]` / `[redacted-sensitive-field]` / + * `[redacted-config]` / `[redacted sensitive context]`。 + */ + +import type { ThreadEvent } from '../generated/ThreadEvent'; +import type { ThreadItem } from '../generated/ThreadItem'; + +const ABSOLUTE_PATH_MARKER = ''; +const REDACTED_SECRET = '[redacted-secret]'; +const REDACTED_SENSITIVE_FIELD = '[redacted-sensitive-field]'; +const REDACTED_CONFIG = '[redacted-config]'; +const REDACTED_SENSITIVE_CONTEXT = '[redacted sensitive context]'; + +/** 有辨识前缀的凭据:前缀 + 最小 body 长度。 */ +const SECRET_PREFIXES: ReadonlyArray = [ + ['tnr_sk_', 8], + ['sk-', 8], + ['ghp_', 20], + ['gho_', 20], + ['ghu_', 20], + ['ghs_', 20], + ['ghr_', 20], + ['npm_', 20], + ['AKIA', 16], + ['ASIA', 16], + ['AIza', 20], + ['sk_live_', 16], + ['rk_live_', 16], + ['xoxb-', 16], + ['xoxp-', 16], + ['xoxa-', 16], + ['xoxr-', 16], +]; + +/** 出现这些开关的行整行换成敏感上下文占位符(值形态不可靠,只能整行遮挡)。 */ +const SENSITIVE_CLI_FLAGS = [ + '--password', + '--token', + '--secret', + '--api-key', + '--apikey', + '--authorization', + '--cookie', +]; + +/** 赋值形式的敏感键:只遮挡值,保留 HTTP 状态等同一行上的其它信息。 */ +const SENSITIVE_ASSIGNMENT_KEYS = [ + 'proxy-authorization', + 'authorization', + 'set-cookie', + 'cookie', + 'access-token', + 'access_token', + 'access token', + 'accesstoken', + 'refresh-token', + 'refresh_token', + 'refresh token', + 'refreshtoken', + 'oauth-token', + 'oauth_token', + 'oauth token', + 'id-token', + 'id_token', + 'id token', + 'auth-token', + 'auth_token', + 'auth token', + 'authtoken', + 'client-secret', + 'client_secret', + 'client secret', + 'clientsecret', + 'private-key', + 'private_key', + 'private key', + 'privatekey', + 'secret-key', + 'secret_key', + 'secret key', + 'secretkey', + 'x-api-key', + 'x_api_key', + 'api-key', + 'api_key', + 'api key', + 'apikey', + 'credentials', + 'credential', + 'password', + 'token', + 'secret', + 'bearer', +]; + +const REDACTION_MARKERS = [ + ABSOLUTE_PATH_MARKER, + REDACTED_SECRET, + REDACTED_SENSITIVE_FIELD, + REDACTED_CONFIG, + REDACTED_SENSITIVE_CONTEXT, + '', + '', +]; + +const PATH_BOUNDARY = new Set([ + ' ', + '\t', + '\n', + '\r', + '"', + "'", + '`', + '=', + '(', + '[', + '{', + '<', + ',', + ';', +]); + +const PATH_TOKEN_STOP = new Set(['"', "'", '`', ')', ']', '}', '>', ',', ';']); + +const RELATIVE_SEGMENT_STOP = new Set([ + "'", + '"', + '`', + ',', + ';', + '|', + '&', + '(', + ')', + '[', + ']', + '{', + '}', + '<', + '>', + ':', +]); + +/** 只小写 ASCII,和宿主口径一致:非 ASCII 路径名不因 locale 变化。 */ +function asciiLower(value: string): string { + return value.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchIndices(value: string, needle: string): number[] { + const indices: number[] = []; + let from = 0; + for (;;) { + const at = value.indexOf(needle, from); + if (at < 0) break; + indices.push(at); + from = at + needle.length; + } + return indices; +} + +function isRedactionMarker(value: string): boolean { + return REDACTION_MARKERS.some((marker) => value.startsWith(marker)); +} + +/** 项目根之后的路径段:分隔符统一成 `/`,遇空白 / 标点停下。 */ +function projectRelativePathSegment( + value: string, + start: number, +): { end: number; relative: string } { + let index = start; + let relative = ''; + while (index < value.length) { + const character = value[index]!; + if (character === '/' || character === '\\') { + if (relative) relative += '/'; + index += 1; + continue; + } + if (/\s/.test(character) || RELATIVE_SEGMENT_STOP.has(character)) break; + relative += character; + index += 1; + } + while (relative.endsWith('/')) relative = relative.slice(0, -1); + return { end: index, relative }; +} + +/** `/game/src/x.ts` → `game/src/x.ts`;只剩项目根本身时按 `` 处理。 */ +function relativizeProjectRootPaths( + projectRoot: string, + value: string, +): string { + const trimmed = projectRoot.replace(/[\\/]+$/, ''); + if (!trimmed) return value; + const needles = [ + ...new Set( + [trimmed, trimmed.replace(/\\/g, '/'), trimmed.replace(/\//g, '\\')] + .map(asciiLower) + .filter((needle) => needle.length > 0), + ), + ].sort(); + if (needles.length === 0) return value; + const lower = asciiLower(value); + + let output = ''; + let cursor = 0; + while (cursor < value.length) { + let hit: { start: number; end: number } | null = null; + for (const needle of needles) { + let search = cursor; + for (;;) { + const found = lower.indexOf(needle, search); + if (found < 0) break; + const start = found; + const end = start + needle.length; + const leftBoundary = + start === 0 || !/[\p{L}\p{N}_-]/u.test(lower[start - 1]!); + const followsSeparator = value[end] === '/' || value[end] === '\\'; + if (leftBoundary && followsSeparator) { + if (hit === null || start < hit.start) hit = { start, end }; + break; + } + search = end; + } + } + if (hit === null) break; + output += value.slice(cursor, hit.start); + const { end, relative } = projectRelativePathSegment(value, hit.end); + output += relative || ABSOLUTE_PATH_MARKER; + cursor = end; + } + return output + value.slice(cursor); +} + +function isPathBoundary(character: string): boolean { + return /\s/.test(character) || PATH_BOUNDARY.has(character); +} + +function consumePathToken(value: string, start: number): number { + let index = start; + while (index < value.length) { + const character = value[index]!; + if (/\s/.test(character) || PATH_TOKEN_STOP.has(character)) break; + index += 1; + } + return index; +} + +function startsForwardSlashUncPath(value: string, index: number): boolean { + const serverStart = index + 2; + const tokenEnd = consumePathToken(value, serverStart); + const slashOffset = value.slice(serverStart, tokenEnd).indexOf('/'); + if (slashOffset < 0) return false; + const serverEnd = serverStart + slashOffset; + const shareStart = serverEnd + 1; + return ( + serverEnd > serverStart && + shareStart < tokenEnd && + value[shareStart] !== '/' + ); +} + +function startsAbsolutePath(value: string, index: number): boolean { + if (index !== 0 && !isPathBoundary(value[index - 1]!)) return false; + const character = value[index]!; + if (character === '/') { + return value[index + 1] !== '/' || startsForwardSlashUncPath(value, index); + } + if (character === '\\' && value[index + 1] === '\\') return true; + return ( + /[A-Za-z]/.test(character) && + value[index + 1] === ':' && + (value[index + 2] === '/' || value[index + 2] === '\\') + ); +} + +function startsFileUri(value: string, index: number): boolean { + if (index !== 0 && !isPathBoundary(value[index - 1]!)) return false; + const prefix = 'file:'; + if (asciiLower(value.slice(index, index + prefix.length)) !== prefix) { + return false; + } + const prefixEnd = index + prefix.length; + const tokenEnd = consumePathToken(value, prefixEnd); + return ( + value[prefixEnd] === '/' && + tokenEnd > prefixEnd && + value.slice(prefixEnd, tokenEnd) !== '//' + ); +} + +function redactAbsolutePaths(value: string): string { + let output = ''; + let index = 0; + while (index < value.length) { + if (startsAbsolutePath(value, index) || startsFileUri(value, index)) { + output += ABSOLUTE_PATH_MARKER; + index = consumePathToken(value, index); + continue; + } + const character = value[index]!; + const code = character.codePointAt(0) ?? 0; + const isControl = code < 0x20 || code === 0x7f; + if ( + isControl && + character !== '\n' && + character !== '\r' && + character !== '\t' + ) { + output += ' '; + } else { + output += character; + } + index += character.length; + } + return output; +} + +function secretTokenEndWithMinimum( + value: string, + index: number, + prefix: string, + minimumBodyLength: number, +): number | null { + if (!prefix) return null; + const startsAtBoundary = + index === 0 || !/[0-9A-Za-z_]/.test(value[index - 1]!); + if (!startsAtBoundary) return null; + const bodyStart = index + prefix.length; + let bodyLength = 0; + let cursor = bodyStart; + let tokenEnd = bodyStart; + while (cursor < value.length) { + const character = value[cursor]!; + if (!/[0-9A-Za-z\-_]/.test(character)) break; + bodyLength += 1; + cursor += 1; + tokenEnd = cursor; + } + return bodyLength >= minimumBodyLength ? tokenEnd : null; +} + +function jwtTokenEnd(value: string, index: number): number | null { + const startsAtBoundary = + index === 0 || !/[0-9A-Za-z_]/.test(value[index - 1]!); + if (!startsAtBoundary) return null; + let cursor = index; + for (let segmentIndex = 0; segmentIndex < 3; segmentIndex += 1) { + const segmentStart = cursor; + while (cursor < value.length && /[0-9A-Za-z\-_]/.test(value[cursor]!)) { + cursor += 1; + } + const minimumSegmentLength = segmentIndex === 2 ? 8 : 3; + if (cursor - segmentStart < minimumSegmentLength) return null; + if (segmentIndex < 2) { + if (value[cursor] !== '.') return null; + cursor += 1; + } + } + const endsAtBoundary = + cursor >= value.length || !/[0-9A-Za-z\-_.]/.test(value[cursor]!); + return endsAtBoundary && cursor - index >= 32 ? cursor : null; +} + +function redactSecretTokens(value: string): string { + const spans: Array<[number, number]> = []; + for (const [prefix, minimumBodyLength] of SECRET_PREFIXES) { + for (const at of matchIndices(value, prefix)) { + const end = secretTokenEndWithMinimum( + value, + at, + prefix, + minimumBodyLength, + ); + if (end !== null) spans.push([at, end]); + } + } + for (const at of matchIndices(value, 'eyJ')) { + const end = jwtTokenEnd(value, at); + if (end !== null) spans.push([at, end]); + } + if (spans.length === 0) return value; + spans.sort((left, right) => left[0] - right[0] || left[1] - right[1]); + let output = ''; + let cursor = 0; + for (const [start, end] of spans) { + if (start < cursor) continue; + output += value.slice(cursor, start); + output += REDACTED_SECRET; + cursor = end; + } + return output + value.slice(cursor); +} + +function containsSensitiveCliFlag(lower: string): boolean { + return lower.split(/\s+/).some((rawToken) => { + const token = rawToken + .replace(/^[,;,;()[\]{}]+/, '') + .replace(/[,;,;()[\]{}]+$/, ''); + return SENSITIVE_CLI_FLAGS.some( + (flag) => token === flag || token.startsWith(`${flag}=`), + ); + }); +} + +const ASSIGNMENT_KEY_PATTERN = [...SENSITIVE_ASSIGNMENT_KEYS] + .sort((left, right) => right.length - left.length) + .map(escapeRegExp) + .join('|'); + +// 键名可被引号 / 转义引号包裹;值可以是引号字符串(含 JSON 转义)、占位符或裸 token。 +const SENSITIVE_ASSIGNMENT = new RegExp( + `(^|[^0-9A-Za-z])((?:\\\\?["'])?)((?:${ASSIGNMENT_KEY_PATTERN}))((?:\\\\?["'])?)(\\s*[:=]>?\\s*)((?:\\\\?["'][^"']*\\\\?["'])|(?:<[^>]*>)|(?:\\[[^\\]]*\\])|(?:[Bb]earer\\s+[^\\s,;,;&\\]})<>]+)|(?:[^\\s,;,;&\\]})<>]+))`, + 'gi', +); + +const BEARER_VALUE = + /(\bbearer\b)(\s+)((?:\\?["'][^"']*\\?["'])|(?:<[^>]*>)|(?:\[[^\]]*\])|(?:[^\s,;,;&\]})<>]+))/gi; + +const CONFIG_NAMES = /(?:\.env|game-creator\.config)[^\s'"`,;::&\]})<>]*/gi; + +function redactErrorAssignments(line: string): string { + return line.replace( + SENSITIVE_ASSIGNMENT, + ( + _match: string, + left: string, + openQuote: string, + _key: string, + closeQuote: string, + separator: string, + value: string, + ) => + `${left}${openQuote}${REDACTED_SENSITIVE_FIELD}${closeQuote}${separator}${ + isRedactionMarker(value) ? value : REDACTED_SECRET + }`, + ); +} + +function redactBearerValues(line: string): string { + return line.replace( + BEARER_VALUE, + (_match: string, bearer: string, whitespace: string, value: string) => + `${bearer}${whitespace}${isRedactionMarker(value) ? value : REDACTED_SECRET}`, + ); +} + +function redactConfigNames(line: string): string { + return line.replace(CONFIG_NAMES, REDACTED_CONFIG); +} + +/** 逐行遮挡:私钥块整块吞掉,敏感开关整行遮挡,其余只遮挡赋值 / Bearer / 配置名。 */ +function sanitizeSensitiveLines(value: string): string { + let output = ''; + let insidePrivateKey = false; + let start = 0; + while (start < value.length) { + const newlineAt = value.indexOf('\n', start); + const terminated = newlineAt >= 0; + let line = terminated ? value.slice(start, newlineAt) : value.slice(start); + start = terminated ? newlineAt + 1 : value.length; + if (line.endsWith('\r')) line = line.slice(0, -1); + const lower = asciiLower(line); + if (containsSensitiveCliFlag(lower)) { + output += REDACTED_SENSITIVE_CONTEXT; + } else if (insidePrivateKey) { + if (lower.includes('-----end') && lower.includes('private key')) { + insidePrivateKey = false; + } + continue; + } else if (lower.includes('-----begin') && lower.includes('private key')) { + output += REDACTED_SENSITIVE_CONTEXT; + insidePrivateKey = !( + lower.includes('-----end') && lower.includes('private key') + ); + } else { + const redacted = redactConfigNames( + redactBearerValues(redactErrorAssignments(redactSecretTokens(line))), + ); + output += redactSecretTokens(redacted); + } + if (terminated) output += '\n'; + } + return output; +} + +/** 一段宿主文本的前端脱敏入口:项目内路径归一 → 绝对路径 → 密钥 → 逐行敏感上下文。 */ +export function sanitizeDirectThreadText( + projectRoot: string | null | undefined, + value: string, +): string { + if (!value) return value; + const withoutProjectRoot = relativizeProjectRootPaths( + projectRoot ?? '', + value, + ); + const withoutAbsolutePaths = redactAbsolutePaths(withoutProjectRoot); + const withoutSecrets = redactSecretTokens(withoutAbsolutePaths); + return sanitizeSensitiveLines(withoutSecrets); +} + +/** 条目投影的脱敏:只改自由文本字段,身份与时间原样保留。 */ +export function sanitizeDirectThreadItem( + projectRoot: string | null | undefined, + item: ThreadItem, +): ThreadItem { + const text = (value: string) => sanitizeDirectThreadText(projectRoot, value); + const optionalText = (value: string | null) => + value === null ? null : text(value); + switch (item.itemType) { + case 'message': + return { ...item, text: text(item.text) }; + case 'reasoning': + return { ...item, text: text(item.text) }; + case 'function_call': + return { + ...item, + name: text(item.name), + arguments: text(item.arguments), + }; + case 'function_call_output': + return { ...item, output: text(item.output) }; + case 'commandExecution': + return { + ...item, + command: text(item.command), + output: optionalText(item.output), + status: optionalText(item.status), + }; + case 'fileChange': + return { + ...item, + changes: item.changes.map((change) => ({ + path: text(change.path), + kind: text(change.kind), + })), + }; + case 'mcpToolCall': + return { + ...item, + tool: text(item.tool), + arguments: text(item.arguments), + output: optionalText(item.output), + status: optionalText(item.status), + }; + case 'webSearch': + return { + ...item, + query: optionalText(item.query), + output: optionalText(item.output), + }; + case 'contextCompaction': + return item; + case 'other': + return { ...item, rawType: text(item.rawType) }; + } +} + +/** 事件投影的脱敏:条目事件与流式增量是唯一的宿主文本载体。 */ +export function sanitizeDirectThreadEvent( + projectRoot: string | null | undefined, + event: ThreadEvent, +): ThreadEvent { + switch (event.type) { + case 'item.started': + return { + ...event, + item: sanitizeDirectThreadItem(projectRoot, event.item), + }; + case 'item.completed': + return { + ...event, + item: sanitizeDirectThreadItem(projectRoot, event.item), + }; + case 'item.delta': + return { + ...event, + delta: sanitizeDirectThreadText(projectRoot, event.delta), + }; + default: + return event; + } +} + +export function sanitizeDirectThreadEvents( + projectRoot: string | null | undefined, + events: readonly ThreadEvent[], +): ThreadEvent[] { + return events.map((event) => sanitizeDirectThreadEvent(projectRoot, event)); +} + +export function sanitizeDirectThreadItems( + projectRoot: string | null | undefined, + items: readonly ThreadItem[], +): ThreadItem[] { + return items.map((item) => sanitizeDirectThreadItem(projectRoot, item)); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts new file mode 100644 index 000000000..262595b47 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts @@ -0,0 +1,128 @@ +import { expectNever } from '../../../../app/expectNever'; +import type { TauriInvoke } from '../../../../app/types'; +import type { EnqueueError } from '../generated/EnqueueError'; + +/** 用户可见文案的主语:与既有运行错误横幅同一份口径。 */ +const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; + +/** + * 命令失败在 JS 侧的载体:`error` 就是 Rust 的结构化入队失败(ts-rs 生成的 + * `EnqueueError` 判别联合)。 + * + * 与认证命令的 `ClientAuthErrorWrapper` 同一套路:构造时把整份载荷 `JSON.stringify` 进 + * `Error.message`(结构化拒绝是普通对象,序列化后上报事件拿到的是机器事实,而不是 + * `[object Object]`),不读任何变体字段、不拼用户文案、不注入上报上下文。分流只看 + * `instanceof` 与 `error.type`,**不要用文案判断**。 + */ +export class EnqueueFailureWrapper extends Error { + readonly error: EnqueueError; + + constructor(error: EnqueueError) { + super(JSON.stringify(error)); + this.name = 'EnqueueFailureWrapper'; + this.error = error; + } +} + +/** + * 入队命令的统一入口:把 Tauri 的拒绝原样装进 `EnqueueFailureWrapper`。 + * + * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,`enqueue_direct_codex_turn` 的拒绝 + * 就是 ts-rs 生成的判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方的 `instanceof` + * 分界会把真 `Error` 送回既有捕获链路。包装本身不读变体字段、不拼文案。 + */ +export async function invokeTurnEnqueue( + invoke: TauriInvoke, + input: { + projectPath: string; + clientTurnId: string; + userItem: unknown; + creationType?: string | null; + }, +): Promise { + try { + await invoke('enqueue_direct_codex_turn', { + projectPath: input.projectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + ...(input.creationType ? { creationType: input.creationType } : {}), + }); + } catch (error) { + // 真 `Error`(Tauri 传输失败 / 命令 panic / 参数序列化失败)不是结构化拒绝: + // `JSON.stringify(new Error(...))` 得到 `{}`,装进 wrapper 只会把真实文案丢掉、让横幅显示 `{}`。 + // 原样抛出,交给调用方的 `instanceof` 分界送回既有捕获链路(与上面的类注释同一口径)。 + if (error instanceof Error) throw error; + throw new EnqueueFailureWrapper(error as EnqueueError); + } +} + +/** + * **用户自己就能改**的入队失败 → 与用户消息同级的提示文案;宿主 / 环境事实变体返回 `null`, + * 由调用方走上报通道。 + * + * 业务变体的句子与改造前宿主 `Display` 的输出逐字一致:文案换了生成方(Rust → 前端),不换 + * 用户看到的那句话。载荷字段(`minChars` / `maxChars` / `limit` / `policyDetail` / `detail`) + * 是拼句子的原料,判断"这是哪一类"只按变体。 + * + * 空 `policyDetail` / `detail` 按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`。 + */ +export function enqueueFailureNotice(failure: EnqueueError): string | null { + switch (failure.type) { + case 'clientTurnIdMissing': + return 'Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份'; + case 'clientTurnIdMalformed': + return `clientTurnId 必须为 ${failure.minChars} 到 ${failure.maxChars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字`; + case 'queueFull': + return `待发消息已达上限(最多 ${failure.limit} 条),请等前面几条发完再发送`; + case 'projectRootUnusable': + return '当前项目目录不存在或不是绝对路径'; + case 'permissionRejected': + return failure.policyDetail?.trim() || null; + case 'inputRejected': + return failure.detail?.trim() || null; + case 'contentEmpty': + return '聊天内容不能为空'; + // 宿主 / 环境事实:必须走上报通道,文案由 `enqueueSystemFailureNoticeText` 给固定句。 + case 'environmentNotReady': + case 'hostStateUnavailable': + return null; + default: { + expectNever(failure); + // 结构化映射外(Tauri 级 / 未来宿主的新变体):按"没有可展示的提示"处理,交给上报通道。 + return null; + } + } +} + +/** + * **宿主 / 环境事实**类入队失败在聊天区里的固定句;认不出形状时返回 `null`。 + * + * 这类失败的 `detail` 是宿主原文(`stage=` / `code=` / 路径这类机器字段都在里面),只用于 + * `.agent/runtime/errors` 与诊断,**不上屏**;所以这里只按变体给一句用户能看懂的话,不回落 + * 任何字段。 + * + * `null` 只给"根本不是这份结构化载荷"的抛出(Tauri / 宿主缺陷,或未来宿主新增的变体): + * 那时说不出用户改哪一处能变好,调用方只上报 + 横幅,不写聊天。 + */ +export function enqueueSystemFailureNoticeText( + failure: EnqueueError, +): string | null { + switch (failure.type) { + case 'environmentNotReady': + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; + case 'hostStateUnavailable': + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; + // 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。 + // 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。 + default: + return null; + } +} + +/** + * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 两条通道永远不会合并成一条。 + */ +export function enqueueFailureNoticeMessageId(userItemId: string) { + return `${userItemId}:rejected`; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index cccd824ba..93b3ba4c2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -1,20 +1,146 @@ /** * 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。 * - * 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): + * 三条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): * 1. 说明条目的展示身份怎么派生; - * 2. 事件里的原始原因怎么变成用户可见文案。 + * 2. 载荷里的 typed 事实怎么变成用户可见文案; + * 3. (没有第 3 条——失败原因的文案只从载荷变体拼,不回落宿主原文。) * - * 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) - * 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人 - * 只能靠猜"这条失败说明是从哪冒出来的"。 + * 为什么值得单独一个文件:前两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) + * 区分开又保持可预期;文案要按变体逐条写死,不能靠对宿主文本做子串匹配。 + * + * 载荷里的 `detail` / `cause` / `diagnostic` 是**宿主原文**(走 `stage=` / `exitStatus=` / + * 路径这类机器字段),只用于诊断与分流、**不上屏**:这里只读 `type` / `stage` / `deadline` / + * `kind` 这些枚举字段。 */ -import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; +import { expectNever } from '../../../../app/expectNever'; +import type { Deadline } from '../generated/Deadline'; +import type { FailureStage } from '../generated/FailureStage'; +import type { ModelCallKind } from '../generated/ModelCallKind'; +import type { NativeKind } from '../generated/NativeKind'; +import type { TurnFailure } from '../generated/TurnFailure'; + +/** 用户可见文案的主语:与运行错误横幅同一份口径。 */ +const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; /** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; +/** 交付阶段标签:与诊断收口文案同一份标签,只有"回合失败"才带。 */ +const DIRECT_FAILURE_STAGE_LABELS: Record = { + 'art-preparation': '平台资源准备失败', + 'code-generation': '代码生成失败', + 'browser-validation': '真实试玩未通过', + 'version-registration': '项目版本登记失败', +}; + +/** + * app-server 原生分类的可见说明。 + * + * 与改造前 `projectRuntimeVisibleError` 里 `codex-app-server-error:` 那张字符串表同一份 + * 文案;改造后不再解析原因文本,直接按 typed 取值查表。新增原生分类时这里漏一条就会被 + * `expectNever` 拦下。 + */ +function directNativeKindText(kind: NativeKind): string { + switch (kind.type) { + case 'context-window-exceeded': + return '模型上下文已超限,请缩小任务范围后重试'; + case 'session-budget-exceeded': + return '本次会话预算已耗尽,请缩小任务范围或新建任务'; + case 'usage-limit-exceeded': + return '用量已达上限,请检查账户额度后重试'; + case 'request-too-large': + return '模型请求体过大,请减少参考图或上下文后重试'; + case 'stream-required': + return '本次执行需要流式回执,当前通道不支持;请重试'; + case 'cyber-policy': + return '安全策略拒绝了本次请求,请调整任务内容'; + case 'sandbox-error': + return '工作区隔离启动失败,请检查项目目录后重试'; + case 'thread-rollback-failed': + return '会话恢复失败,请新建任务后重试'; + case 'bad-request': + return '请求无效,请稍后重试'; + case 'unauthorized': + return '鉴权失败,请重新登录后重试'; + case 'active-turn-not-steerable': + return '当前任务无法追加指令,请等待结束后重试'; + case 'other': + return '未完成本次执行,请查看项目文件是否已修改后再重试'; + default: { + expectNever(kind); + return '未完成本次执行,请查看项目文件是否已修改后再重试'; + } + } +} + +/** 模型调用失败的可见说明:有原生分类看原生分类,没有就看平台层那一层的分类。 */ +function directModelCallText(kind: ModelCallKind | null | undefined): string { + // 载荷跨 IPC 没有运行时校验:`kind` 缺失时按"没有分类"兜底,别让整条事件订阅在这一步抛错。 + if (!kind) { + return '智能服务执行失败,请稍后重试'; + } + switch (kind.type) { + case 'responseTimedOut': + return '等待模型回执超时,本轮未完成;请稍后重试'; + case 'connectionFailed': + return '执行通道未能建立或已断开,本轮未完成;请重试,若持续失败请检查项目诊断'; + case 'transportBroken': + case 'streamUnavailable': + return '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断'; + case 'requestRejected': + return kind.native + ? directNativeKindText(kind.native) + : '智能创作请求无效,请稍后重试'; + case 'upstreamFailed': + return ( + (kind.native ? directNativeKindText(kind.native) : null) ?? + directUpstreamStatusText(kind.statusCode) + ); + case 'paidCreditsInsufficient': + return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; + case 'emptyResponse': + return '模型未返回内容,请重试;如持续失败请检查项目诊断'; + case 'payloadInvalid': + return '模型回执无法解析,请重试;如持续失败请检查项目诊断'; + default: { + expectNever(kind); + return '智能服务执行失败,请稍后重试'; + } + } +} + +function directUpstreamStatusText(statusCode: number): string { + switch (statusCode) { + case 401: + return '上游服务拒绝认证(HTTP 401),请重新登录后重试'; + case 403: + return '上游服务拒绝访问(HTTP 403),请检查账号权限后重试'; + case 408: + return '上游请求超时(HTTP 408),请稍后重试'; + case 429: + return '上游服务繁忙(HTTP 429),请稍后重试'; + default: + return statusCode >= 500 + ? `智能服务暂时不可用(HTTP ${statusCode}),请稍后重试` + : `智能创作请求被上游拒绝(HTTP ${statusCode}),请检查请求后重试`; + } +} + +function directTurnDeadlineText(deadline: Deadline): string { + switch (deadline) { + case 'response-idle': + return '等待模型执行回执超时,本轮未完成;请稍后重试'; + case 'turn-hard-limit': + return '本轮执行超出时间上限,已停止;请核对项目文件后重试'; + default: { + expectNever(deadline); + return '等待模型执行回执超时,本轮未完成;请稍后重试'; + } + } +} + /** * 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。 * @@ -40,17 +166,47 @@ export function directTurnFailureItemId( } /** - * 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。 + * 失败原因的可见文案:按载荷变体逐条写死,不再把宿主原文送进字符串映射。 * - * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 - * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + * 改造前这里读的是宿主 `Display` 生成的整句原因,再靠 `projectRuntimeVisibleError` 做子串匹配; + * 宿主换一句事实句而前端没跟着加模式时,用户拿到的就是通用兜底。现在载荷直接给 typed 事实, + * 文案由前端在这里按变体拼:**新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。 * - * **不加模式就只会看到通用文案**:`projectRuntimeVisibleError` 只认它自己那份模式表,宿主换一句 - * 新的 `Display` 事实句而这边没跟着加模式时,用户拿到的就是"…执行失败,请稍后重试"。这是有意的 - * 取舍——宁可给通用文案,也不回落宿主原文(原文可能带 `exitStatus=` / `stderrClass=` 这类内部 - * 字段)。加了新模式就补一条 `agentRuntimeModel.test.ts` 的用例。 + * `detail` / `cause` / `diagnostic` 一律不上屏:它们是宿主原文,可能带 `exitStatus=` / + * `stderrClass=` 这类内部字段,只用于 `.agent/runtime/errors` 与诊断。 */ -export function directTurnFailureNoticeText(message: string): string { - const raw = typeof message === 'string' ? message.trim() : ''; - return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true); +export function directTurnFailureNoticeText(failure: TurnFailure): string { + switch (failure.type) { + case 'projectRootUnanchored': + return `${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`; + case 'environmentNotReady': + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`; + case 'hostStateUnavailable': + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`; + case 'modelCallFailed': + return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind)}`; + case 'transportClosed': + return `${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`; + case 'timedOut': + return `${DIRECT_TURN_SUBJECT} ${directTurnDeadlineText(failure.deadline)}`; + case 'turnInterrupted': + return `${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`; + case 'superErrorFromStringPlusStage': { + // 载荷跨 IPC 没有运行时校验:stage 缺失或是更新后端新增的取值时查不到标签, + // 不能把 `undefined` 拼进用户可见文案,回落到通用句。 + const stageLabel = DIRECT_FAILURE_STAGE_LABELS[failure.stage]; + return stageLabel + ? `${DIRECT_TURN_SUBJECT}:${stageLabel},请检查项目诊断后重试` + : `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + } + case 'unclassified': + return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + case 'hostDropped': + return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。'; + default: { + expectNever(failure); + // 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。 + return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + } + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts new file mode 100644 index 000000000..1d374f0df --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `clientTurnId` 的长度约束:上下限随载荷带出去,界面不自己数一份。 + */ +export type ClientTurnIdMalformed = { minChars: number, maxChars: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ConsumeResult.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ConsumeResult.ts index f31d8f49c..2b2143553 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ConsumeResult.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ConsumeResult.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadEvent } from './ThreadEvent'; +import type { ThreadEvent } from "./ThreadEvent"; -export type ConsumeResult = { events: Array }; +export type ConsumeResult = { events: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts similarity index 80% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts index 83f2fe79b..a1e7d80bf 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts @@ -5,4 +5,4 @@ * * 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 */ -export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; +export type Deadline = "response-idle" | "turn-hard-limit"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts deleted file mode 100644 index 7394ae07d..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type DirectCodexUserAttachmentReferencePart = { - name: string; - mediaType: string; - size: number; - localPath: string; - status: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts deleted file mode 100644 index d92fdb7fa..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts +++ /dev/null @@ -1,23 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; -import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; - -export type DirectCodexUserContentPart = - | { type: 'input_text'; text: string } - | { - type: 'agc_resource_reference'; - resourceId: string; - /** - * 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 - * 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 - * 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 - */ - resolvedText?: string | null; - } - | { type: 'agc_skill_reference'; name: string } - | ({ - type: 'agc_runtime_region_reference'; - } & DirectCodexUserRuntimeRegionPart) - | ({ - type: 'agc_attachment_reference'; - } & DirectCodexUserAttachmentReferencePart); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts deleted file mode 100644 index a1101c8cb..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; -import type { DirectCodexUserRole } from './DirectCodexUserRole'; - -export type DirectCodexUserMessageItem = { - role: DirectCodexUserRole; - content: Array; - id: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts deleted file mode 100644 index b134fb7bc..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts +++ /dev/null @@ -1,13 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type DirectCodexUserRuntimeRegionPart = { - label: string; - runId: string | null; - versionId: string | null; - elementTag: string | null; - elementRole: string | null; - text: string | null; - width: number | null; - height: number | null; - resourceIds: Array; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts deleted file mode 100644 index 1c39e2058..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts +++ /dev/null @@ -1,24 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; - -/** - * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 - * - * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 - * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 - * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 - */ -export type DirectModelCallKind = - | { type: 'responseTimedOut'; attempts: number } - | { type: 'connectionFailed'; attempts: number } - | { type: 'transportBroken' } - | { type: 'streamUnavailable' } - | { type: 'requestRejected'; native: DirectCodexNativeKind | null } - | { - type: 'upstreamFailed'; - statusCode: number; - native: DirectCodexNativeKind | null; - } - | { type: 'paidCreditsInsufficient' } - | { type: 'emptyResponse' } - | { type: 'payloadInvalid'; native: DirectCodexNativeKind | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts deleted file mode 100644 index 3e3496ab8..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectTurnError } from './DirectTurnError'; - -/** - * 入队失败载荷:命令边界交给前端的**结构化入队失败**。 - * - * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 - * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 - * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 - */ -export type DirectTurnEnqueueFailure = { - /** - * 结构化变体:界面按 `error.type` 分流,不解析文案。 - */ - error: DirectTurnError; - /** - * 可展示文案(`Display` 的唯一出口)。 - */ - message: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts deleted file mode 100644 index 22c445d40..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ /dev/null @@ -1,27 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexFailureStage } from './DirectCodexFailureStage'; -import type { DirectModelCallKind } from './DirectModelCallKind'; -import type { DirectTurnDeadline } from './DirectTurnDeadline'; - -/** - * 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 - */ -export type DirectTurnError = - | { type: 'clientTurnIdMissing' } - | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } - | { type: 'queueFull'; limit: number } - | { type: 'projectRootUnanchored'; cause: string } - | { type: 'projectRootUnusable' } - | { type: 'permissionRejected'; policyDetail: string } - | { type: 'inputRejected'; detail: string } - | { type: 'contentEmpty' } - | { type: 'environmentNotReady'; detail: string } - | { type: 'hostStateUnavailable'; detail: string } - | { type: 'modelCallFailed'; kind: DirectModelCallKind; detail: string } - | { type: 'transportClosed'; diagnostic: string } - | { type: 'timedOut'; deadline: DirectTurnDeadline } - | { type: 'turnInterrupted'; detail: string } - | { type: 'reviewRequired'; detail: string } - | { type: 'repairRequired'; detail: string } - | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } - | { type: 'turnFailedUnclassified'; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts deleted file mode 100644 index 5b343123c..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectTurnFailureKind } from './DirectTurnFailureKind'; - -/** - * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 - * - * `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 - * 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 - */ -export type DirectTurnFailure = { - /** - * 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 - * [`DirectTurnError::wire_kind`]。 - */ - kind: DirectTurnFailureKind; - /** - * 脱敏 + 截断后的失败原因。 - */ - message: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts deleted file mode 100644 index 11275386d..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 - * - * 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 - * 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, - * 枚举成员名与线上取值一一对应,改名即改协议。 - */ -export type DirectTurnFailureKind = - | 'timeout' - | 'model-failed' - | 'transport-failed' - | 'request-rejected' - | 'environment-not-ready' - | 'turn-interrupted' - | 'host-dropped'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts new file mode 100644 index 000000000..b904681ab --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientTurnIdMalformed } from "./ClientTurnIdMalformed"; +import type { EnvironmentNotReady } from "./EnvironmentNotReady"; +import type { HostStateUnavailable } from "./HostStateUnavailable"; +import type { InputRejected } from "./InputRejected"; +import type { PermissionRejected } from "./PermissionRejected"; +import type { QueueFull } from "./QueueFull"; + +/** + * 命令边界的 typed 入队失败:变体名就是线上的分流键(`type`),前端只按它选通道,不解析文案。 + * + * 可达集合由 `direct_runtime::user_input::enqueue_direct_codex_turn_typed` 固定: + * `clientTurnId` 校验 → 工作流恢复 → 用户条目冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 + * 放行之后发生的失败不在这张表里(见 [`TurnError`])。 + */ +export type EnqueueError = { "type": "clientTurnIdMissing" } | { "type": "clientTurnIdMalformed" } & ClientTurnIdMalformed | { "type": "queueFull" } & QueueFull | { "type": "projectRootUnusable" } | { "type": "permissionRejected" } & PermissionRejected | { "type": "inputRejected" } & InputRejected | { "type": "contentEmpty" } | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts new file mode 100644 index 000000000..848a2e7c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 环境 / 凭据 / 脚手架未就绪的原始解释。 + * + * **两张表共用**:入队侧是入队失败,回合侧是回合失败。 + */ +export type EnvironmentNotReady = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts similarity index 73% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts index a7040d769..314f31ec9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts @@ -5,8 +5,4 @@ * * 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 */ -export type DirectCodexFailureStage = - | 'art-preparation' - | 'code-generation' - | 'browser-validation' - | 'version-registration'; +export type FailureStage = "art-preparation" | "code-generation" | "browser-validation" | "version-registration"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HistorySlice.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HistorySlice.ts index 22a93077c..b56f593e7 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HistorySlice.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HistorySlice.ts @@ -1,14 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadItem } from './ThreadItem'; +import type { ThreadItem } from "./ThreadItem"; -export type HistorySlice = { - /** - * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 - */ - items: Array; - hasMore: boolean; - /** - * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 - */ - firstItemId: string | null; -}; +export type HistorySlice = { +/** + * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + */ +items: Array, hasMore: boolean, +/** + * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + */ +firstItemId: string | null, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts new file mode 100644 index 000000000..d558145ad --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主执行账本取不到的原始解释。 + */ +export type HostStateUnavailable = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts new file mode 100644 index 000000000..05c13bdb0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 用户条目 / 创建类型不合法的原始解释。 + */ +export type InputRejected = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts new file mode 100644 index 000000000..bebd8d9f5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelCallKind } from "./ModelCallKind"; + +/** + * 模型调用失败的分类与平台层原文。 + */ +export type ModelCallFailed = { kind: ModelCallKind, detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts new file mode 100644 index 000000000..b5ba63eb5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NativeKind } from "./NativeKind"; + +/** + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 + * + * 每个变体对应平台层 `LlmError` 的一个分支;前端拿它选语气,不拿它做流程分支。 + * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 + */ +export type ModelCallKind = { "type": "responseTimedOut", attempts: number, } | { "type": "connectionFailed", attempts: number, } | { "type": "transportBroken" } | { "type": "streamUnavailable" } | { "type": "requestRejected", native: NativeKind | null, } | { "type": "upstreamFailed", statusCode: number, native: NativeKind | null, } | { "type": "paidCreditsInsufficient" } | { "type": "emptyResponse" } | { "type": "payloadInvalid", native: NativeKind | null, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts similarity index 58% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts index 1347200b1..b38ba07b2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts @@ -9,16 +9,4 @@ * * 线上取值只给界面选语气用,前端不得拿它做流程分支。 */ -export type DirectCodexNativeKind = - | { type: 'context-window-exceeded' } - | { type: 'session-budget-exceeded' } - | { type: 'usage-limit-exceeded' } - | { type: 'request-too-large' } - | { type: 'stream-required' } - | { type: 'cyber-policy' } - | { type: 'sandbox-error' } - | { type: 'thread-rollback-failed' } - | { type: 'bad-request' } - | { type: 'unauthorized' } - | { type: 'active-turn-not-steerable' } - | { type: 'other'; kind: string }; +export type NativeKind = { "type": "context-window-exceeded" } | { "type": "session-budget-exceeded" } | { "type": "usage-limit-exceeded" } | { "type": "request-too-large" } | { "type": "stream-required" } | { "type": "cyber-policy" } | { "type": "sandbox-error" } | { "type": "thread-rollback-failed" } | { "type": "bad-request" } | { "type": "unauthorized" } | { "type": "active-turn-not-steerable" } | { "type": "other", kind: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts new file mode 100644 index 000000000..bd36bdd08 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 项目权限策略的原文(带被拒的点位)。 + */ +export type PermissionRejected = { policyDetail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts new file mode 100644 index 000000000..dd888d55e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 项目目录锚不定的原始原因。 + */ +export type ProjectRootUnanchored = { cause: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts new file mode 100644 index 000000000..61904ef17 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 待发消息队列上限:只落在宿主这一处(`MAX_PENDING_TURNS`)。 + */ +export type QueueFull = { limit: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalOutcome.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalOutcome.ts index 109b6f6ba..07a82e74a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalOutcome.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalOutcome.ts @@ -3,4 +3,4 @@ /** * 取消一条待发消息的结果。 */ -export type QueueRemovalOutcome = 'removed' | 'alreadyDispatched' | 'notFound'; +export type QueueRemovalOutcome = "removed" | "alreadyDispatched" | "notFound"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalReason.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalReason.ts index f965308e6..a66bf169a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalReason.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueRemovalReason.ts @@ -6,4 +6,4 @@ * typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 * (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 */ -export type QueueRemovalReason = 'cancelled' | 'dispatched'; +export type QueueRemovalReason = "cancelled" | "dispatched"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SubscriptionBootstrap.ts index de5eb7404..fcfa6bbc5 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SubscriptionBootstrap.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SubscriptionBootstrap.ts @@ -1,14 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadEvent } from './ThreadEvent'; +import type { ThreadEvent } from "./ThreadEvent"; -export type SubscriptionBootstrap = { - subscriptionId: string; - /** - * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 - */ - lastCompletedItemId: string | null; - /** - * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 - */ - events: Array; -}; +export type SubscriptionBootstrap = { subscriptionId: string, +/** + * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + */ +lastCompletedItemId: string | null, +/** + * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + */ +events: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SuperErrorFromStringPlusStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SuperErrorFromStringPlusStage.ts new file mode 100644 index 000000000..94050cfad --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/SuperErrorFromStringPlusStage.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FailureStage } from "./FailureStage"; + +/** + * 已知交付阶段的失败:`stage` 是 typed enum,`detail` 是产生层原文(字符串债)。 + * + * 名字故意起得又长又扎眼(`Super…FromStringPlusStage`):与 [`SuperErrorEnumFromStringTyped`] + * 同一笔债——`detail` 一天没 typed 化,它一天不配有个正常名字。 + */ +export type SuperErrorFromStringPlusStage = { stage: FailureStage, detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadDeltaKind.ts index b414a0f81..30edebe27 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadDeltaKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadDeltaKind.ts @@ -3,4 +3,4 @@ /** * 增量正文属于哪类条目。 */ -export type ThreadDeltaKind = 'message' | 'reasoning'; +export type ThreadDeltaKind = "message" | "reasoning"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts index 184fe739b..fd3894301 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts @@ -1,10 +1,11 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserItem } from './DirectCodexUserItem'; -import type { DirectTurnFailure } from './DirectTurnFailure'; -import type { QueueRemovalReason } from './QueueRemovalReason'; -import type { ThreadDeltaKind } from './ThreadDeltaKind'; -import type { ThreadItem } from './ThreadItem'; -import type { ThreadRequestKind } from './ThreadRequestKind'; +import type { QueueRemovalReason } from "./QueueRemovalReason"; +import type { ThreadDeltaKind } from "./ThreadDeltaKind"; +import type { ThreadItem } from "./ThreadItem"; +import type { ThreadRequestKind } from "./ThreadRequestKind"; +import type { TurnCompletedStatus } from "./TurnCompletedStatus"; +import type { TurnFailure } from "./TurnFailure"; +import type { UserItem } from "./UserItem"; /** * Thread Manager 下发的运行态事件。 @@ -31,76 +32,51 @@ import type { ThreadRequestKind } from './ThreadRequestKind'; * + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 * (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 */ -export type ThreadEvent = - | { - type: 'turn.started'; - /** - * 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 - * `turn/start` 的时刻)。 - */ - at?: number; - /** - * 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 - */ - userItemId?: string; - } - | { - type: 'turn.completed'; - /** - * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, - * 此时必须带 `failure` 载荷。 - */ - status: string; - /** - * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 - */ - failure?: DirectTurnFailure; - /** - * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 - */ - at?: number; - /** - * 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 - */ - userItemId?: string; - } - | { - type: 'item.started'; - item: ThreadItem; - /** - * 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - */ - at?: number; - } - | { - type: 'item.completed'; - item: ThreadItem; - /** - * 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - */ - at?: number; - } - | { type: 'item.delta'; itemId: string; kind: ThreadDeltaKind; delta: string } - | { type: 'request'; kind: ThreadRequestKind; requestId: string | null } - | { - type: 'queue.enqueued'; - /** - * 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 - */ - clientTurnId: string; - /** - * canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 - */ - userItem: DirectCodexUserItem; - creationType?: string; - /** - * 入队那一刻的宿主毫秒钟。 - */ - at: number; - } - | { - type: 'queue.removed'; - clientTurnId: string; - reason: QueueRemovalReason; - at?: number; - }; +export type ThreadEvent = { "type": "turn.started", +/** + * 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * `turn/start` 的时刻)。 + */ +at?: number, +/** + * 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + */ +userItemId?: string, } | { "type": "turn.completed", +/** + * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场,不带载荷;`failed` 是 + * **失败**,一定带 `failure` 载荷。正常收场的构造参数是 [`TurnOutcome`](不含 `Failed`), + * [`ThreadEvent::turn_completed`] 因此写不出"failed 但没有载荷"。 + */ +status: TurnCompletedStatus, +/** + * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + */ +failure?: TurnFailure, +/** + * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + */ +at?: number, +/** + * 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + */ +userItemId?: string, } | { "type": "item.started", item: ThreadItem, +/** + * 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + */ +at?: number, } | { "type": "item.completed", item: ThreadItem, +/** + * 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + */ +at?: number, } | { "type": "item.delta", itemId: string, kind: ThreadDeltaKind, delta: string, } | { "type": "request", kind: ThreadRequestKind, requestId: string | null, } | { "type": "queue.enqueued", +/** + * 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + */ +clientTurnId: string, +/** + * canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + */ +userItem: UserItem, creationType?: string, +/** + * 入队那一刻的宿主毫秒钟。 + */ +at: number, } | { "type": "queue.removed", clientTurnId: string, reason: QueueRemovalReason, at?: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadFileChange.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadFileChange.ts index 5ef619872..2559d43ed 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadFileChange.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadFileChange.ts @@ -3,10 +3,8 @@ /** * 一条文件变更。 */ -export type ThreadFileChange = { - path: string; - /** - * `add` | `update` | `delete` - */ - kind: string; -}; +export type ThreadFileChange = { path: string, +/** + * `add` | `update` | `delete` + */ +kind: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadItem.ts index cbb236f88..a2207f250 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadItem.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadItem.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadFileChange } from './ThreadFileChange'; +import type { ThreadFileChange } from "./ThreadFileChange"; /** - * 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 + * 聊天视图的输入条目:一条 Codex 原始条目的原样投影(脱敏与限长在事件进前端状态时统一做)。 * * `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 * 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 @@ -10,67 +10,16 @@ import type { ThreadFileChange } from './ThreadFileChange'; * 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, * 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 */ -export type ThreadItem = - | { - itemType: 'message'; - /** - * 归一身份:全链路只有这一个 id。 - */ - itemId: string; - /** - * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 - */ - role: string; - text: string; - at: number; - } - | { itemType: 'reasoning'; itemId: string; text: string; at: number } - | { - itemType: 'function_call'; - itemId: string; - name: string; - arguments: string; - at: number; - } - | { - itemType: 'function_call_output'; - itemId: string; - output: string; - at: number; - } - | { - itemType: 'commandExecution'; - itemId: string; - command: string; - output: string | null; - /** - * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … - */ - status: string | null; - exitCode: number | null; - at: number; - } - | { - itemType: 'fileChange'; - itemId: string; - changes: Array; - at: number; - } - | { - itemType: 'mcpToolCall'; - itemId: string; - tool: string; - arguments: string; - output: string | null; - status: string | null; - at: number; - } - | { - itemType: 'webSearch'; - itemId: string; - query: string | null; - output: string | null; - at: number; - } - | { itemType: 'contextCompaction'; itemId: string; at: number } - | { itemType: 'other'; itemId: string; rawType: string; at: number }; +export type ThreadItem = { "itemType": "message", +/** + * 归一身份:全链路只有这一个 id。 + */ +itemId: string, +/** + * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + */ +role: string, text: string, at: number, } | { "itemType": "reasoning", itemId: string, text: string, at: number, } | { "itemType": "function_call", itemId: string, name: string, arguments: string, at: number, } | { "itemType": "function_call_output", itemId: string, output: string, at: number, } | { "itemType": "commandExecution", itemId: string, command: string, output: string | null, +/** + * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + */ +status: string | null, exitCode: number | null, at: number, } | { "itemType": "fileChange", itemId: string, changes: Array, at: number, } | { "itemType": "mcpToolCall", itemId: string, tool: string, arguments: string, output: string | null, status: string | null, at: number, } | { "itemType": "webSearch", itemId: string, query: string | null, output: string | null, at: number, } | { "itemType": "contextCompaction", itemId: string, at: number, } | { "itemType": "other", itemId: string, rawType: string, at: number, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadRequestKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadRequestKind.ts index 1b946a72b..83b0d57db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadRequestKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadRequestKind.ts @@ -3,7 +3,4 @@ /** * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 */ -export type ThreadRequestKind = - | 'approval.requested' - | 'ask.requested' - | 'request.resolved'; +export type ThreadRequestKind = "approval.requested" | "ask.requested" | "request.resolved"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts new file mode 100644 index 000000000..189383a55 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Deadline } from "./Deadline"; + +/** + * 撞上的那条上限。 + */ +export type TimedOut = { deadline: Deadline, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts new file mode 100644 index 000000000..ed8b1657d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 通道断开时那份宿主诊断(进程退出 / stderr 摘要)。 + */ +export type TransportClosed = { diagnostic: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts new file mode 100644 index 000000000..fc42cfa41 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `turn.completed` 的终态取值。 + * + * 正常收场是前三档、不带载荷;`Failed` **一定**带 [`TurnFailure`] 载荷,只由 + * [`ThreadEvent::turn_completed_failed`] 写出。 + */ +export type TurnCompletedStatus = "completed" | "interrupted" | "aborted" | "failed"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts new file mode 100644 index 000000000..a0f8a2996 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts @@ -0,0 +1,23 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EnvironmentNotReady } from "./EnvironmentNotReady"; +import type { HostStateUnavailable } from "./HostStateUnavailable"; +import type { ModelCallFailed } from "./ModelCallFailed"; +import type { ProjectRootUnanchored } from "./ProjectRootUnanchored"; +import type { SuperErrorFromStringPlusStage } from "./SuperErrorFromStringPlusStage"; +import type { TimedOut } from "./TimedOut"; +import type { TransportClosed } from "./TransportClosed"; +import type { TurnInterrupted } from "./TurnInterrupted"; +import type { Unclassified } from "./Unclassified"; + +/** + * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 + * + * 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 + * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 + * 不直接上屏。 + * + * 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 + * [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 + * 不需要为它写分支。 + */ +export type TurnFailure = { "type": "projectRootUnanchored" } & ProjectRootUnanchored | { "type": "environmentNotReady" } & EnvironmentNotReady | { "type": "hostStateUnavailable" } & HostStateUnavailable | { "type": "modelCallFailed" } & ModelCallFailed | { "type": "transportClosed" } & TransportClosed | { "type": "timedOut" } & TimedOut | { "type": "turnInterrupted" } & TurnInterrupted | { "type": "superErrorFromStringPlusStage" } & SuperErrorFromStringPlusStage | { "type": "unclassified" } & Unclassified | { "type": "hostDropped" }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts new file mode 100644 index 000000000..9003001d1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * app-server 判成中断的原始说明。 + */ +export type TurnInterrupted = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Unclassified.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Unclassified.ts new file mode 100644 index 000000000..6aa1cdb5e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Unclassified.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 深层尚未 typed 的字符串错误:不知道发生在哪一段,只有一条宿主原文。 + */ +export type Unclassified = { detail: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts new file mode 100644 index 000000000..91e4e0a4d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UserAttachmentReferencePart = { name: string, mediaType: string, size: number, localPath: string, status: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts new file mode 100644 index 000000000..1f23ca820 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserAttachmentReferencePart } from "./UserAttachmentReferencePart"; +import type { UserRuntimeRegionPart } from "./UserRuntimeRegionPart"; + +export type UserContentPart = { "type": "input_text", text: string, } | { "type": "agc_resource_reference", resourceId: string, +/** + * 这个引用被宿主解析出来的文本:素材摘要,加上引用的 UI 设计文档展开的代码上下文。 + * 入队冻结时写入一次,此后随条目持久化,prompt / 历史回读 / turn input 共用这一份。 + * 缺省合法(旧历史没有它照样解析),缺省时才退回按当前 manifest 现算。 + */ +resolvedText?: string | null, } | { "type": "agc_skill_reference", name: string, } | { "type": "agc_runtime_region_reference" } & UserRuntimeRegionPart | { "type": "agc_attachment_reference" } & UserAttachmentReferencePart; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts similarity index 52% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts index aa1ee88dd..be7d93b4e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts @@ -1,9 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +import type { UserMessageItem } from "./UserMessageItem"; /** * DirectProject 本轮 user input 的唯一结构化入口。 */ -export type DirectCodexUserItem = { - type: 'message'; -} & DirectCodexUserMessageItem; +export type UserItem = { "type": "message" } & UserMessageItem; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts new file mode 100644 index 000000000..adb9f5889 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserContentPart } from "./UserContentPart"; +import type { UserRole } from "./UserRole"; + +export type UserMessageItem = { role: UserRole, content: Array, id: string, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts similarity index 72% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts index 0c2540122..dbdefa551 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DirectCodexUserRole = 'user'; +export type UserRole = "user"; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts new file mode 100644 index 000000000..0aa389f10 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type UserRuntimeRegionPart = { label: string, runId: string | null, versionId: string | null, elementTag: string | null, elementRole: string | null, text: string | null, width: number | null, height: number | null, resourceIds: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index df69d90a0..50e0c00dc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -3348,8 +3348,9 @@ export default function ProjectDevelopmentView({ * 两条判据缺一不可: * 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作; * 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源 - * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同 - * (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。 + * 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目**不一定相同** + * (Agent / Direct 路径不传 `targetCategory`,普通图片仍落「待归类」),所以这里按**正式归类**写, + * 而不是入口栏目。 * * 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。 */ @@ -9062,9 +9063,15 @@ export default function ProjectDevelopmentView({ [selectResourceCanvasPage], ); - /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ + /** + * 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 + * + * 入口栏目随上传一起交给原生(`targetCategory`):上传的 kind 只由内容证据推导 + * (图片 / 视频 / 代码 → `unclassified`),不带上它,素材会落进「待归类」、 + * 在上传它的那一栏里看不见。取值就是工具栏自己的栏目,与生成入口同一口径。 + */ const uploadResourceCanvasToolbarFiles = useCallback( - async (files: readonly File[]) => { + async (files: readonly File[], targetCategory: ResourceCategory | null) => { const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { setResourceWorkbenchNotice('上传素材需要在客户端内打开'); @@ -9088,6 +9095,7 @@ export default function ProjectDevelopmentView({ bytes: Array.from(new Uint8Array(await file.arrayBuffer())), })), ), + targetCategory, invoke, }); setResourceWorkbenchNotice(`已上传 ${uploadable.length} 个素材`); @@ -10548,7 +10556,10 @@ export default function ProjectDevelopmentView({ } onSelectAction={handleResourceBottomToolAction} onUploadFiles={(files) => { - void uploadResourceCanvasToolbarFiles(files); + void uploadResourceCanvasToolbarFiles( + files, + resourceCanvasBottomToolbarCategory, + ); }} uploading={resourceBottomToolbarUploading} /> diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index 79b322249..35b7163bd 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -1,4 +1,7 @@ -import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { + GameCreationAppManifest, + ProjectResourceCanvasCategory, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ProjectManifestSnapshotSource = | 'initial' @@ -285,6 +288,14 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { mediaType: string; bytes: number[]; }[]; + /** + * 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。 + * + * 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`), + * 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。 + * 判定与失败关闭都在原生:非法值报错,前端不做伪分类。 + */ + targetCategory?: ProjectResourceCanvasCategory | null; invoke(command: string, args: Record): Promise; }): Promise { for (const file of input.files) { @@ -293,6 +304,7 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: { fileName: file.fileName, mediaType: file.mediaType, bytes: file.bytes, + ...(input.targetCategory ? { targetCategory: input.targetCategory } : {}), }); } const fresh = await rereadAuthoritativeProjectManifestSnapshot({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 747cecbfa..e8c6f5ee7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,4 +1,4 @@ -import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import * as platformSession from '../../src/services/platformSession'; import { appendDictationText, @@ -221,7 +221,7 @@ function pendingTurn( ): DirectPendingTurn { return { clientTurnId, - userItem: directCodexUserItemFromContent( + userItem: userItemFromContent( [{ type: 'input_text', text }], `direct-codex:${clientTurnId}:user`, ), @@ -313,7 +313,7 @@ export function registerChatComposerControlTests() { it('待发消息 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { const turn: DirectPendingTurn = { clientTurnId: 'client-ref', - userItem: directCodexUserItemFromContent( + userItem: userItemFromContent( [ { type: 'input_text', text: '用这张图改一下' }, { type: 'agc_resource_reference', resourceId: 'asset:hero' }, @@ -377,10 +377,8 @@ export function registerChatComposerControlTests() { // 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。 const { invoke, surface } = await openDirectCodexSurface({ enqueue_direct_codex_turn: () => { - throw { - error: { type: 'queueFull', limit: 5 }, - message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - }; + // 命令拒绝就是 Rust 的结构化入队失败本身(JS 侧只包一层载体)。 + throw { type: 'queueFull', limit: 5 }; }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); @@ -536,12 +534,8 @@ export function registerChatComposerControlTests() { const { surface } = await openDirectCodexSurface({ enqueue_direct_codex_turn: () => { throw { - error: { - type: 'environmentNotReady', - detail: 'Codex app-server 启动失败:找不到可执行文件', - }, - message: - 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', }; }, }); @@ -551,11 +545,14 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getByText( - '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', ), ).not.toBeNull(); }); - // 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。 + // 宿主原文(`detail` 里的可执行文件路径、机器字段)只留在诊断里,不进聊天。 + expect(conversation.textContent ?? '').not.toContain( + 'Codex app-server 启动失败', + ); expect(conversation.textContent ?? '').not.toContain( 'direct-codex-failure', ); @@ -766,8 +763,12 @@ export function registerChatComposerControlTests() { type: 'turn.completed', status: 'failed', failure: { - kind: 'request-rejected', - message: 'codex-app-server-error:context-window-exceeded', + type: 'modelCallFailed', + kind: { + type: 'requestRejected', + native: { type: 'context-window-exceeded' }, + }, + detail: 'codex-app-server-error:context-window-exceeded', }, at: 6_000, userItemId, @@ -829,8 +830,8 @@ export function registerChatComposerControlTests() { type: 'turn.completed', status: 'failed', failure: { - kind: 'environment-not-ready', - message: '写入本项目对话历史失败:项目对话历史追加写失败', + type: 'environmentNotReady', + detail: '写入本项目对话历史失败:项目对话历史追加写失败', }, at: 5_100, userItemId, @@ -847,11 +848,11 @@ export function registerChatComposerControlTests() { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '落盘失败的那条'); const conversation = await within(surface).findByLabelText('陶泥儿消息'); - // 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。 + // 说明来自 `turn.completed.failure`,按载荷变体拼;命令返回 `Ok` 不再写第二条。 await waitFor(() => { expect( within(conversation).getAllByText( - '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试', ), ).toHaveLength(1); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index da09d1b52..b933c7933 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2583,24 +2583,12 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 - persistedMessages.push( - { - itemType: 'message', - itemId: `direct-codex:${clientTurnId}:user`, - role: 'user', - text: '生成一个游戏', - at: 9_000, - }, - { - itemType: 'message', - itemId: `direct-codex:${clientTurnId}:assistant`, - role: 'assistant', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - at: 9_001, - }, - ); - throw new Error('codex-app-server-error:unauthorized'); + // 命令拒绝就是 Rust 的结构化入队失败本身(JS 侧只包一层载体):不再有字符串错误、 + // 也不再有前端对错误文本做子串映射。 + throw { + type: 'permissionRejected', + policyDetail: '项目权限策略拒绝了本次调用', + }; } throw new Error(`unexpected invoke ${command}`); }, @@ -2615,43 +2603,39 @@ export function registerHomeProjectCreationTests() { }), ); + // 拒绝原因由前端按 typed 变体拼成与用户消息同级的提示;宿主原文(`type=` 这类机器字段) + // 不上屏,也不落成伪造的历史条目。 expect( - await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), + await screen.findAllByText('项目权限策略拒绝了本次调用'), ).not.toBeNull(); - await waitFor(() => { - expect(persistedMessages).toHaveLength(2); - }); - expect(persistedMessages).toEqual([ - { - itemType: 'message', - itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - role: 'user', - text: '生成一个游戏', - at: 9_000, - }, - { - itemType: 'message', - itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - role: 'assistant', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - at: 9_001, - }, - ]); - expect(JSON.stringify(persistedMessages)).not.toContain( - 'codex-app-server-error:unauthorized', - ); + expect(screen.queryAllByText(/permissionRejected/)).toHaveLength(0); + // 入队没成立:被拒消息不得留下任何历史条目,也不得被乐观渲染成已成立的用户消息。 + expect(persistedMessages).toHaveLength(0); + expect(screen.queryAllByText('生成一个游戏')).toHaveLength(0); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'append_local_conversation_message', + ), + ).toHaveLength(0); + // 入队没成立:只发一次命令,失败之后不自动重试。 + expect( + invoke.mock.calls.filter( + ([command]) => command === 'enqueue_direct_codex_turn', + ), + ).toHaveLength(1); cleanup(); + // 重进项目:被拒消息不得从历史里被重放,拒绝文案也不该被当成历史条目。 render( React.createElement(App, { initialProjectPath: projectPath, initialProjectManifest: manifest, }), ); - expect(await screen.findByText('生成一个游戏')).not.toBeNull(); - expect( - await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), - ).not.toBeNull(); + await waitFor(() => { + expect(screen.queryAllByText('生成一个游戏')).toHaveLength(0); + }); + expect(screen.queryAllByText('项目权限策略拒绝了本次调用')).toHaveLength(0); expect( invoke.mock.calls.filter( ([command]) => command === 'enqueue_direct_codex_turn', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 80f549411..967302480 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,10 +1,10 @@ -import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { userItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { directCodexPolicyRetryInput } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { - directCodexPolicyRetryInput, - directTurnEnqueueFailureNotice, - directTurnUnrecognizedEnqueueFailureNoticeText, - readDirectTurnEnqueueFailure, -} from '../../src/view/project-development/chat/conversation/directCodexConversation'; + enqueueFailureNotice, + EnqueueFailureWrapper, + enqueueSystemFailureNoticeText, +} from '../../src/view/project-development/chat/conversation/directTurnEnqueueFailure'; import { createGameCreationAppManifest, emptyProjectPolicy, @@ -22,66 +22,78 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { - // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 - const queueFull = { - error: { type: 'queueFull' as const, limit: 5 }, - message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - }; - expect(readDirectTurnEnqueueFailure(queueFull)?.error.type).toBe( - 'queueFull', - ); - expect(directTurnEnqueueFailureNotice(queueFull)).toBe( + // 入队失败是**结构化**载荷:分流只看 `instanceof` 与 `error.type`,文案不参与任何判断, + // 文案也不再由宿主生成——业务变体由前端按变体拼,句子与改造前宿主 `Display` 逐字一致。 + const queueFull = { type: 'queueFull' as const, limit: 5 }; + expect(enqueueFailureNotice(queueFull)).toBe( '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ); - // 认得的参数 / 前置条件类都给同级提示。 + // 载荷字段是拼句子的原料:上下限 / 上限都来自 Rust,界面不自己数一份。 expect( - directTurnEnqueueFailureNotice({ - error: { type: 'contentEmpty' }, - message: '聊天内容不能为空', - }), - ).toBe('聊天内容不能为空'); - // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 - expect( - directTurnEnqueueFailureNotice({ - error: { type: 'environmentNotReady', detail: '连不上 app-server' }, - message: '环境未就绪', - }), - ).toBeNull(); - expect( - directTurnEnqueueFailureNotice({ - error: { type: 'hostStateUnavailable', detail: '账本损坏' }, - message: '宿主状态取不到', - }), - ).toBeNull(); - // 认不出的入队失败在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / - // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 - expect( - directTurnUnrecognizedEnqueueFailureNoticeText({ - error: { type: 'environmentNotReady', detail: '连不上 app-server' }, - message: - 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + enqueueFailureNotice({ + type: 'clientTurnIdMalformed', + minChars: 6, + maxChars: 160, }), ).toBe( - '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + 'clientTurnId 必须为 6 到 160 位 ASCII 字母、数字或连字符,且首位必须为字母或数字', + ); + expect(enqueueFailureNotice({ type: 'contentEmpty' })).toBe( + '聊天内容不能为空', ); - // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 expect( - directTurnUnrecognizedEnqueueFailureNoticeText({ - error: { type: 'hostStateUnavailable', detail: '账本损坏' }, - message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', + enqueueFailureNotice({ + type: 'permissionRejected', + policyDetail: '项目权限策略拒绝执行:conversation.write', }), - ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); - // 不是这份结构(旧字符串、Error、裸对象)一律不认。 + ).toBe('项目权限策略拒绝执行:conversation.write'); expect( - readDirectTurnEnqueueFailure('codex-app-server-error:unauthorized'), + enqueueFailureNotice({ + type: 'inputRejected', + detail: ' 用户条目不合法 ', + }), + ).toBe('用户条目不合法'); + // 空原文按"没有提示"处理:要么给一条能显示的话,要么给 null。 + expect( + enqueueFailureNotice({ type: 'permissionRejected', policyDetail: ' ' }), ).toBeNull(); - expect(readDirectTurnEnqueueFailure(new Error('boom'))).toBeNull(); + + // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里只给固定句,不回落 `detail`。 expect( - readDirectTurnEnqueueFailure({ error: {}, message: 'x' }), + enqueueFailureNotice({ + type: 'environmentNotReady', + detail: '连不上 app-server', + }), ).toBeNull(); expect( - readDirectTurnEnqueueFailure({ error: { type: 'contentEmpty' } }), + enqueueFailureNotice({ + type: 'hostStateUnavailable', + detail: '账本损坏', + }), ).toBeNull(); + expect( + enqueueSystemFailureNoticeText({ + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', + }), + ).toBe( + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + ); + expect( + enqueueSystemFailureNoticeText({ + type: 'hostStateUnavailable', + detail: '账本损坏:exitStatus=signal: 9 (SIGKILL)', + }), + ).toBe('陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试'); + + // JS 侧载体:结构化载荷装进 Error,`error` 与 `message`(序列化的机器事实)都带出去。 + const wrapper = new EnqueueFailureWrapper({ + type: 'queueFull', + limit: 5, + }); + expect(wrapper instanceof Error).toBe(true); + expect(wrapper.error.type).toBe('queueFull'); + expect(wrapper.message).toBe('{"type":"queueFull","limit":5}'); }); it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { @@ -91,7 +103,7 @@ export function registerProjectConversationTests() { const firstTurn = { clientTurnId: 'direct-turn-1', creationType: 'game' as const, - userItem: directCodexUserItemFromContent( + userItem: userItemFromContent( [ { type: 'input_text' as const, text: '用这张图改一下' }, { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index fd8e5bebf..b66ab4c34 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -8839,6 +8839,52 @@ export function registerProjectAgentStatusTests() { ); }, 20_000); + it('uploads toolbar files into the entry column so they stay visible where they were uploaded', async () => { + // Issue 359:图片只由内容证据派生成 `unclassified`,在「角色与对象」栏目里上传后 + // 会落进「待归类」、在上传它的那一栏里看不见。工具栏上传必须把当前栏目交给原生。 + const manifest = createGameCreationAppManifest( + 'workbench-bottom-toolbar-upload-category', + '底部工具栏上传归类项目', + ); + const { calls } = installResourceBookBottomToolbarInvoke(manifest); + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + chat: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('角色与对象'); + const uploadInput = screen.getByLabelText( + '上传素材文件', + ) as HTMLInputElement; + const file = new File(['png'], 'hero.png', { type: 'image/png' }); + if (typeof file.arrayBuffer !== 'function') { + Object.defineProperty(file, 'arrayBuffer', { + value: async () => new Uint8Array([112, 110, 103]).buffer, + }); + } + fireEvent.change(uploadInput, { target: { files: [file] } }); + + await waitFor(() => + expect(uploadCall(calls, 'hero.png')).toMatchObject({ + projectPath: '/tmp/workbench-bottom-toolbar-upload-category', + fileName: 'hero.png', + mediaType: 'image/png', + targetCategory: 'character', + bytes: [112, 110, 103], + }), + ); + }, 20_000); + it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => { const styles = readFileSync( repoPath('apps/ai-game-creator-shell/src/styles.css'), diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index 3d7f2998f..c7b29ab60 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -32,7 +32,7 @@ import { hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; const storage = new Map(); Object.defineProperty(window, 'localStorage', { @@ -88,7 +88,7 @@ async function composerText() { } /** 纯文本草稿的 canonical content。 */ -function textContent(text: string): DirectCodexUserContentPart[] { +function textContent(text: string): UserContentPart[] { return text ? [{ type: 'input_text', text }] : []; } @@ -102,7 +102,7 @@ function ControlledChatComposer({ onSubmitDraft: (draft: ChatComposerDraft) => void; }) { const composerRef = useRef(null); - const initialContent: DirectCodexUserContentPart[] = [ + const initialContent: UserContentPart[] = [ ...textContent(initialText), ...initialReferences.map(chatReferenceToContentPart), ]; diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index c5a7bffc9..c495cf109 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -449,8 +449,8 @@ describe('DirectProject 聊天 reducer', () => { type: 'turn.completed', status: 'failed', failure: { - kind: 'transport-failed', - message: 'DirectProject 收尾历史失败:未确认历史完整落盘', + type: 'transportClosed', + diagnostic: 'DirectProject 收尾历史失败:未确认历史完整落盘', }, at: 1_000_900, }), @@ -464,9 +464,7 @@ describe('DirectProject 聊天 reducer', () => { const notice = failed.history.at(-1); expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); expect(notice?.role).toBe('assistant'); - expect(notice?.text).toBe( - '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', - ); + expect(notice?.text).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); @@ -482,8 +480,12 @@ describe('DirectProject 聊天 reducer', () => { type: 'turn.completed', status: 'failed', failure: { - kind: 'request-rejected', - message: 'codex-app-server-error:context-window-exceeded', + type: 'modelCallFailed', + kind: { + type: 'requestRejected', + native: { type: 'context-window-exceeded' }, + }, + detail: 'codex-app-server-error:context-window-exceeded', }, at: 2_000, }), @@ -503,7 +505,10 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + failure: { + type: 'unclassified', + detail: '模型服务暂不可用', + }, at: 1_000_900, }), 'direct-codex:turn-1:user', @@ -515,7 +520,10 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + failure: { + type: 'unclassified', + detail: '模型服务暂不可用', + }, at: 9_900_000, }), 'direct-codex:turn-1:user', @@ -539,7 +547,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '上一轮的失败' }, + failure: { type: 'unclassified', detail: '上一轮的失败' }, at: 2_000_900, userItemId: 'direct-codex:turn-1:user', }), @@ -550,19 +558,14 @@ describe('DirectProject 聊天 reducer', () => { expect(untouched.live).toHaveLength(0); }); - it('空原因不落说明条目,但终态照样收口', () => { + it('没有失败载荷时不落说明条目,但终态照样收口', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ withUserItemId( event({ type: 'turn.started', at: 1_000 }), 'direct-codex:turn-1:user', ), withUserItemId( - event({ - type: 'turn.completed', - status: 'failed', - failure: { kind: 'host-dropped', message: ' ' }, - at: 2_000, - }), + event({ type: 'turn.completed', status: 'failed', at: 2_000 }), 'direct-codex:turn-1:user', ), ]); @@ -571,27 +574,27 @@ describe('DirectProject 聊天 reducer', () => { expect(failed.history).toHaveLength(0); }); - it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => { - // 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求 - // "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 - for (const message of [undefined, null]) { - const malformed = { - type: 'turn.completed', - status: 'failed', - at: 2_000, - failure: { kind: 'host-dropped', message }, - } as unknown as ThreadEvent; - const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - withUserItemId( - event({ type: 'turn.started', at: 1_000 }), - 'direct-codex:turn-1:user', - ), - malformed, - ]); - expect(failed.turnRunning).toBe(false); - expect(failed.turnEndedAt).toBe(2_000); - expect(failed.history).toHaveLength(0); - } + it('未知失败变体不抛错,按通用文案收口', () => { + // 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。这里只要求 + // "不抛错 + 不把宿主原文当文案",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 + const malformed = { + type: 'turn.completed', + status: 'failed', + at: 2_000, + failure: { type: 'futureVariant', detail: '宿主原文不得上屏' }, + } as unknown as ThreadEvent; + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + malformed, + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history.at(-1)?.text).toBe( + '陶泥儿智能创作 执行失败,请稍后重试', + ); }); it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => { @@ -604,7 +607,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 2_000, }), 'direct-codex:turn-1:user', @@ -624,7 +627,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第一轮失败' }, + failure: { type: 'unclassified', detail: '第一轮失败' }, at: 2_000, }), ]); @@ -651,7 +654,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 3_000, }), 'direct-codex:turn-1:user', @@ -668,7 +671,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 3_000, }), 'direct-codex:turn-1:user', @@ -684,7 +687,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第一轮失败' }, + failure: { type: 'unclassified', detail: '第一轮失败' }, at: 2_000, }), ]); @@ -693,7 +696,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第二轮失败' }, + failure: { type: 'unclassified', detail: '第二轮失败' }, at: 4_000, }), ]); diff --git a/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts new file mode 100644 index 000000000..434c7acb0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { + sanitizeDirectThreadEvent, + sanitizeDirectThreadText, +} from '../src/view/project-development/chat/conversation/directThreadSanitize'; +import type { ThreadEvent } from '../src/view/project-development/chat/generated/ThreadEvent'; + +const ROOT = '/workspace/direct-project'; + +describe('directThreadSanitize', () => { + it('项目内绝对路径归一成项目相对路径', () => { + expect( + sanitizeDirectThreadText( + ROOT, + 'open /workspace/direct-project/game/src/x.ts', + ), + ).toBe('open game/src/x.ts'); + }); + + it('项目外绝对路径与 file:// 换成占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'at /root/secret/x')).toBe( + 'at ', + ); + expect(sanitizeDirectThreadText(ROOT, 'file:///etc/passwd')).toBe( + '', + ); + expect(sanitizeDirectThreadText(ROOT, 'win C:\\Users\\me\\x.txt')).toBe( + 'win ', + ); + }); + + it('有前缀的密钥与 JWT 换成占位符', () => { + const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcdefgh'; + expect(sanitizeDirectThreadText(ROOT, 'key=sk-abcdefghijklmnop')).toBe( + 'key=[redacted-secret]', + ); + expect(sanitizeDirectThreadText(ROOT, `token ${jwt}`)).toBe( + 'token [redacted-secret]', + ); + }); + + it('敏感赋值只遮挡值,保留同行其它信息', () => { + expect(sanitizeDirectThreadText(ROOT, 'password=hunter2 status=401')).toBe( + '[redacted-sensitive-field]=[redacted-secret] status=401', + ); + expect(sanitizeDirectThreadText(ROOT, '"token": "raw-value"')).toBe( + '"[redacted-sensitive-field]": [redacted-secret]', + ); + }); + + it('Bearer 值换成占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'Authorization: Bearer abc123')).toBe( + '[redacted-sensitive-field]: [redacted-secret]', + ); + expect(sanitizeDirectThreadText(ROOT, 'h x Bearer abc123 y')).toBe( + 'h x Bearer [redacted-secret] y', + ); + // 带引号的 Bearer 值(含空格)也要整段遮掉,不能只遮到第一个空格。 + expect(sanitizeDirectThreadText(ROOT, 'h x Bearer "abc def" y')).toBe( + 'h x Bearer [redacted-secret] y', + ); + }); + + it('.env / game-creator.config 换成配置占位符', () => { + expect(sanitizeDirectThreadText(ROOT, 'cat .env.local')).toBe( + 'cat [redacted-config]', + ); + }); + + it('私钥块整块吞掉', () => { + const pem = [ + '-----BEGIN PRIVATE KEY-----', + 'MIIEvQIBADANBg', + '-----END PRIVATE KEY-----', + '', + ].join('\n'); + expect(sanitizeDirectThreadText(ROOT, pem)).toBe( + '[redacted sensitive context]\n', + ); + }); + + it('敏感开关整行遮挡', () => { + expect( + sanitizeDirectThreadText(ROOT, 'curl --token abc123 https://x'), + ).toBe('[redacted sensitive context]'); + }); + + it('已有占位符不再二次变形', () => { + expect(sanitizeDirectThreadText(ROOT, 'at ')).toBe( + 'at ', + ); + }); + + it('多行文本保留换行', () => { + const value = '第一段\nkey=sk-abcdefghijklmnop\n第三段\n'; + expect(sanitizeDirectThreadText(ROOT, value)).toBe( + '第一段\nkey=[redacted-secret]\n第三段\n', + ); + }); + + it('条目事件只改自由文本字段,身份与时间原样', () => { + const event: ThreadEvent = { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: 'key=sk-abcdefghijklmnop at /root/secret/x', + at: 1_700_000_000_000, + }, + at: 1_700_000_000_100, + }; + expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: 'key=[redacted-secret] at ', + at: 1_700_000_000_000, + }, + at: 1_700_000_000_100, + }); + }); + + it('流式增量事件遮挡正文', () => { + const event: ThreadEvent = { + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: 'see /root/secret/x', + }; + expect(sanitizeDirectThreadEvent(ROOT, event)).toEqual({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: 'see ', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts new file mode 100644 index 000000000..2eb77fcc3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directTurnFailure.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { directTurnFailureNoticeText } from '../src/view/project-development/chat/conversation/directTurnFailure'; + +describe('DirectProject 上游失败文案', () => { + it.each([401, 403, 408, 429, 500, 502, 503, 504])( + '保留 HTTP %s 的具体原因', + (statusCode) => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { type: 'upstreamFailed', statusCode, native: null }, + detail: `上游 HTTP ${statusCode}`, + }); + + expect(text).toContain(`HTTP ${statusCode}`); + expect(text).not.toContain('智能创作请求无效'); + }, + ); + + it('原生分类优先于 HTTP 状态文案', () => { + const text = directTurnFailureNoticeText({ + type: 'modelCallFailed', + kind: { + type: 'upstreamFailed', + statusCode: 429, + native: { type: 'context-window-exceeded' }, + }, + detail: 'upstream', + }); + + expect(text).toContain('模型上下文已超限'); + expect(text).not.toContain('HTTP 429'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 6cfb4cd75..05a19b7b4 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -18,7 +18,7 @@ import { type ResourceReference, type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; function asset( id: string, @@ -42,7 +42,7 @@ const heroAsset = asset( 'assets/hero-idle.png', ); -const runtimeRegionPart: DirectCodexUserContentPart = { +const runtimeRegionPart: UserContentPart = { type: 'agc_runtime_region_reference', label: '主画面', runId: 'run-1', @@ -55,7 +55,7 @@ const runtimeRegionPart: DirectCodexUserContentPart = { resourceIds: ['asset-hero'], }; -const attachmentPart: DirectCodexUserContentPart = { +const attachmentPart: UserContentPart = { type: 'agc_attachment_reference', name: 'brief.md', mediaType: 'text/markdown', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index 552683625..cb7c5f6f2 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -26,7 +26,7 @@ import { resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; import { generationPromptText, typeGenerationPrompt, @@ -441,7 +441,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { // 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。 const first = onSubmit.mock.calls[0]?.[0] as { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx index f2d1b33e3..071f8299e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx @@ -16,7 +16,7 @@ import { } from '../src/features/project-workspace/resourceReferences'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; afterEach(cleanup); @@ -106,7 +106,7 @@ describe('生成浮层里的引用选择(真实组件,不做 props mock)', await user.click(within(panel).getByRole('button', { name: '生成图片' })); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0] as { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; // 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。 expect( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index 12c1c1abb..71fb85546 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -4,7 +4,7 @@ import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared import { buildContentFromPastedText, type ChatComposerDraft, - chatComposerDraftToDirectCodexUserItem, + chatComposerDraftToUserItem, type ChatReference, chatReferenceMentionToken, contentPartText, @@ -15,7 +15,7 @@ import { resourceLabelResolver, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; describe('DirectProject user Response item', () => { it('保留 Lexical content 的文本与引用交错顺序', () => { @@ -39,9 +39,7 @@ describe('DirectProject user Response item', () => { ], }; - expect( - chatComposerDraftToDirectCodexUserItem(draft, 'turn-1:user'), - ).toEqual({ + expect(chatComposerDraftToUserItem(draft, 'turn-1:user')).toEqual({ type: 'message', role: 'user', id: 'turn-1:user', @@ -57,9 +55,7 @@ describe('DirectProject user Response item', () => { ], }; - expect( - chatComposerDraftToDirectCodexUserItem(draft, 'turn-2:user'), - ).toEqual({ + expect(chatComposerDraftToUserItem(draft, 'turn-2:user')).toEqual({ type: 'message', role: 'user', id: 'turn-2:user', @@ -80,9 +76,9 @@ describe('DirectProject user Response item', () => { ], }; - expect( - chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, - ).toEqual(draft.content); + expect(chatComposerDraftToUserItem(draft, 'turn-3:user').content).toEqual( + draft.content, + ); }); it('只有最终 content 全为空白时才判定为空输入', () => { @@ -278,7 +274,7 @@ describe('粘贴文本反解析', () => { it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => { // 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。 - const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({ + const runtimeRegionPart = (label: string): UserContentPart => ({ type: 'agc_runtime_region_reference', label, runId: null, @@ -312,7 +308,7 @@ describe('粘贴文本反解析', () => { }); it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { - const content: DirectCodexUserContentPart[] = [ + const content: UserContentPart[] = [ { type: 'input_text', text: '看 ' }, { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: ' 这一版,再用 ' }, diff --git a/docs/README.md b/docs/README.md index 802dfd4e3..5bd922b16 100644 --- a/docs/README.md +++ b/docs/README.md @@ -138,6 +138,8 @@ - [预览画布缩放滑杆](./【交互设计】预览画布缩放滑杆-2026-09-05.md) - [图片画布撤销、恢复范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md) - [微信虚拟支付接入](./【技术方案】微信虚拟支付接入-2026-05-26.md) +- [外部产品支付服务接入](./【技术方案】外部产品支付服务接入-2026-10-03.md) +- [支付服务接入与收银台使用说明](./【使用说明】支付服务接入与收银台使用说明-2026-10-03.md) - [SSE 客户端传输层收口约定](./technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md) - [全站客服悬浮入口接入约定](./technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md) - [图片画布素材导出方案](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md) diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index 3ee3053a5..83fa95574 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -52,7 +52,14 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 `runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段 由报告侧的 sanitize 换成占位符。 - `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。 - 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。 + 这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), EnqueueError>`),不是新约定。 + DirectProject 的三条通道固定为三张类型表:入队拒绝 `EnqueueError`、宿主内部回合错误 `TurnError` + (不导出、不跨进程)、`turn.completed.failure` 载荷 `TurnFailure`;类型随所属深模块命名 + (`agent/codex_app_server/turn_error.rs`、`agent/thread_manager/wire/failure.rs`),不再带 `Direct` 前缀; + 回合终态判定 `TurnCompletion` 是宿主内部判别联合(`agent/thread_manager/turn_completion.rs`),不导出、不进 `wire/`。 + `TurnFailure` 里仍带字符串的两臂专门标成"待清的债":`SuperErrorFromStringPlusStage`(`stage` 是 typed 枚举、 + `detail` 仍是产生层字符串)与 `Unclassified`(连阶段都没有),名字故意起丑,`detail` typed 化后即改名; + `TurnError::classify`(返回 `TurnErrorClassified{ShouldStop, ShouldContinue}`)是**唯一**投影点,任何地方都不许再把 typed 失败重包成"阶段失败 + 预拼文案"。 - 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用 `BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`), 不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 63a51cbac..c5a7b82bc 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -43,6 +43,10 @@ { "name": "Agent Integration", "description": "远程 MCP、OpenAPI 和完整 Skill 包发现" + }, + { + "name": "Payment", + "description": "平台统一收款与 Web 收银台" } ], "paths": { @@ -786,6 +790,46 @@ } }, "/api/external/v1/editor/assets/folders/{folderId}": { + "get": { + "tags": ["Editor Assets"], + "operationId": "getEditorAssetFolder", + "summary": "按 ID 读取当前账号素材文件夹", + "description": "要求 editor:asset scope。仅按 folderId 读取目录并校验当前账号归属,不读取素材列表或创建默认目录;空目录也可读取。目录不存在返回 404,无权访问返回 403。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FolderId" + } + ], + "responses": { + "200": { + "description": "素材文件夹", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorAssetFolderResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, "patch": { "tags": ["Editor Assets"], "operationId": "updateEditorAssetFolder", @@ -1574,6 +1618,52 @@ } } } + }, + "/api/external/v1/payment/orders": { + "post": { + "tags": ["Payment"], + "operationId": "createExternalPaymentOrder", + "summary": "创建支付订单并返回收银台地址", + "description": "使用支付应用 API Key 创建平台统一收款订单。支付金额按整数分传入;支付成功必须以平台通知或服务端查单为准。", + "security": [{"PaymentApiKey": []}], + "parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/PaymentOrderCreateRequest"} + } + } + }, + "responses": { + "200": { + "description": "支付订单与收银台信息", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}} + }, + "400": {"$ref": "#/components/responses/BadRequest"}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "502": {"$ref": "#/components/responses/UpstreamError"} + } + } + }, + "/api/external/v1/payment/orders/{orderId}": { + "get": { + "tags": ["Payment"], + "operationId": "getExternalPaymentOrder", + "summary": "查询支付订单", + "security": [{"PaymentApiKey": []}], + "parameters": [{"name": "orderId", "in": "path", "required": true, "schema": {"type": "string"}}], + "responses": { + "200": { + "description": "支付订单", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}} + }, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "403": {"$ref": "#/components/responses/Forbidden"}, + "404": {"$ref": "#/components/responses/NotFound"} + } + } } }, "components": { @@ -1582,6 +1672,11 @@ "type": "http", "scheme": "bearer", "bearerFormat": "tnr_sk" + }, + "PaymentApiKey": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "tnr_pay" } }, "parameters": { @@ -1707,6 +1802,47 @@ } }, "schemas": { + "PaymentOrderCreateRequest": { + "type": "object", + "required": ["merchantOrderId", "title", "amountCents"], + "properties": { + "merchantOrderId": {"type": "string", "minLength": 1, "maxLength": 96}, + "title": {"type": "string", "minLength": 1, "maxLength": 128}, + "items": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "amountCents": {"type": "integer", "minimum": 1}, + "currency": {"type": "string", "default": "CNY"}, + "provider": {"type": "string", "enum": ["wechat_native"], "default": "wechat_native"} + }, + "additionalProperties": false + }, + "PaymentOrder": { + "type": "object", + "required": ["orderId", "appId", "merchantOrderId", "title", "items", "amountCents", "currency", "provider", "checkoutToken", "status", "checkoutUrl", "createdAt", "expiresAt", "updatedAt"], + "properties": { + "orderId": {"type": "string"}, + "appId": {"type": "string"}, + "merchantOrderId": {"type": "string"}, + "title": {"type": "string"}, + "items": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "amountCents": {"type": "integer", "minimum": 1}, + "currency": {"type": "string"}, + "provider": {"type": "string", "enum": ["wechat_native"]}, + "providerTradeNo": {"type": ["string", "null"]}, + "checkoutToken": {"type": "string"}, + "status": {"type": "string", "enum": ["pending", "paying", "paid", "expired", "closed", "refunded"]}, + "checkoutUrl": {"type": "string"}, + "providerQrCode": {"type": ["string", "null"]}, + "createdAt": {"type": "string", "format": "date-time"}, + "expiresAt": {"type": "string", "format": "date-time"}, + "paidAt": {"type": ["string", "null"], "format": "date-time"}, + "updatedAt": {"type": "string", "format": "date-time"} + } + }, + "PaymentOrderCreateResponse": { + "type": "object", + "required": ["order"], + "properties": {"order": {"$ref": "#/components/schemas/PaymentOrder"}} + }, "ExternalEditorProjectCreateRequest": { "type": "object", "properties": { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 14b25c309..9e38ea713 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -164,7 +164,7 @@ - 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。 - 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。 -- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 +- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`,并带上当前栏目 `targetCategory`(上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;不带入口栏目素材就落进「待归类」、在上传它的那一栏里看不见,Issue 359)。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。 - 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。 - 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。 - 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index a7948a8a4..6c434c4ee 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -27,7 +27,7 @@ ## 验证命令 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` -2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 ts-rs 绑定);生成物保持原始输出直接提交,不要再跑 prettier(见 `decision-log.md` 2026-10-03「生成绑定不再经 prettier」) 3. `npm run test -- apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts` 4. `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`(`project-development.suite.ts` 由该入口注册,不能作为独立测试入口;同时验证现役 Design Agent 的会话恢复与审批界面。) 5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 diff --git a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..aaa18517a --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,31 @@ +# Web 预检浏览器失败恢复实现计划 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md) + +## 实现顺序 + +1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 +2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。 +3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 +4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 +5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests` +- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` +- `npm run typecheck --workspace apps/ai-game-creator-shell` +- `npm run check:encoding` +- `git diff --check` + +可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。 + +## 风险与回滚 + +- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。 +- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。 +- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。 diff --git a/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md b/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md new file mode 100644 index 000000000..8694d80bb --- /dev/null +++ b/docs/project-memory/plans/【实施计划】支付应用与订单收银台-2026-10-03.md @@ -0,0 +1,52 @@ +# 【实施计划】支付应用与订单收银台 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md` | +| Status | completed | +| Owner | Codex | + +## 修改边界 + +允许修改: + +- 主规范、shared-contracts、OpenAPI、支付领域 module、spacetime-module / migration / bindings、spacetime-client facade、api-server payment module、platform-wechat provider。 +- `src` 的 profile 支付入口、收银台页面、支付客户端和页面样式。 +- `apps/admin-web` 的支付 API 类型、路由、页面和后台样式。 +- 与本里程碑直接相关的测试和文档索引。 + +明确不修改: + +- 现有个人钱包充值、微信虚拟支付和退款状态机的行为。 +- 生产商户密钥、环境文件、个人配置和构建产物。 +- 支付宝 provider、分账、提现、手续费和自动结算。 + +## 实现顺序 + +1. 从现有充值订单、微信 Native provider、外部 API Key、profile 路由和后台列表组件提取可复用边界,确认没有重复的公开订单模型。 +2. 冻结支付 DTO、状态枚举、错误码、幂等语义和 OpenAPI,然后补契约测试。 +3. 增加支付领域表、migration、procedure / facade 和 schema 绑定,先完成订单创建、查询、关闭与幂等。 +4. 在 `platform-wechat` 增加支付服务需要的 Native 请求 / 响应和通知确认复用,保持商户配置只在服务端。 +5. 在 `api-server` 接入外部 API、收银台 read model、微信通知、查单和外部回调 outbox;所有到账入口复用统一确认事务。 +6. 增加个人中心支付应用 / Key / 订单页面和公共收银台,按截图参考实现桌面双栏与移动端纵向布局。 +7. 增加后台支付概览、应用、订单、通知和回调投递页面,复用 `packages/shared` 后台公共组件。 +8. 运行定向测试、schema / OpenAPI / 编码检查,启动 api-server 做 healthz 和 mock provider smoke;条件具备时执行真实沙箱二维码 smoke。 + +## 验证命令 + +1. `npm run check:encoding` +2. `git diff --check` +3. `npm run check:spacetime-schema` +4. `npm run typecheck` +5. `npm run admin-web:typecheck` +6. 支付领域和 api-server 定向 `cargo test` +7. 外部 API、profile、收银台和 admin 页面定向 Vitest +8. `npm run dev:api-server` 后检查 `/healthz` +9. mock provider + Playwright 收银台 smoke;真实商户沙箱可用时再补动态二维码验证 + +## 风险与回滚点 + +- 商户 API 证书或通知地址不可用时,保留 provider mock 证据,不把 mock 结果标成真实支付通过。 +- schema 变更失败时停止在绑定生成前,保留主规范和计划,不能通过删除数据绕过迁移。 +- 订单确认事务或回调 outbox 未完成时,关闭新支付渠道开关,不影响现有充值渠道。 +- 发现需要余额、提现、分账或支付宝真实接入时,先更新主规范和后续里程碑,再扩展实现。 diff --git a/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..e56e78d5c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,41 @@ +# Web 预检浏览器失败恢复 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架) + +## 目标 + +修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。 + +## 边界 + +- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。 +- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。 +- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。 +- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。 +- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。 + +## 行为合同 + +1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。 +2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。 +3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。 +4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。 +5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。 + +## 验收标准 + +- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。 +- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。 +- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。 +- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。 + +## 依赖 + +- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs` +- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs` +- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs` +- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts` +- 对应 Rust 与 Vitest 测试。 diff --git a/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md b/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md new file mode 100644 index 000000000..7cf9e4f34 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md @@ -0,0 +1,59 @@ +# 【里程碑】支付应用与订单收银台 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | completed | +| Date | 2026-10-03 | +| Parent Spec | `docs/【技术方案】外部产品支付服务接入-2026-10-03.md` | + +## 目标 + +建立首个可运行的支付服务闭环:账号创建支付应用并管理 API Key,客户端或外部产品创建订单,用户进入 Web 收银台,平台通过微信 Native provider 生成动态二维码,通知 / 查单确认订单,之后通过签名回调通知接入方,后台可查询应用和订单。 + +## 范围 + +- 支付应用、API Key 元数据和账号归属。 +- 外部支付订单、商品快照、支付尝试和状态机。 +- 微信 Native 动态二维码 provider 适配;支付宝只保留渠道扩展结构,不纳入本里程碑的可用渠道。 +- 公共收银台和移动端纵向布局。 +- 支付平台通知、主动查单、确认幂等和接入方回调 outbox。 +- 个人中心支付服务入口、应用配置页和订单列表。 +- 后台支付总览、应用列表、订单列表、通知 / 回调失败列表。 +- `/api/external/v1/payment/*` OpenAPI、共享 DTO、契约测试和支付领域 schema。 + +## 不在范围内 + +- 支付宝真实下单、退款、分账和沙箱联调。 +- 外部产品余额、手续费、提现、自动结算和自动分账。 +- 用户钱包充值商品的迁移或现有微信虚拟支付协议改造。 +- 自动履约发货、数字权益回收和支付争议处理。 + +## 依赖与前置条件 + +- 平台微信 Native 商户号、API 证书 / 密钥和正式或沙箱通知地址可由部署环境注入。 +- 当前微信支付 Native provider、充值订单状态与验签测试可复用。 +- 外部回调域名必须通过应用配置的 HTTPS 和白名单校验。 +- SpacetimeDB schema、migration、生成绑定和 API OpenAPI 可以在同一里程碑同步更新。 + +## 验收标准 + +- [x] 账号能创建、停用支付应用,API Key 仅在创建响应中展示一次,撤销后立即拒绝请求。 +- [x] 外部 API 使用 `Idempotency-Key` 创建订单,重放返回原订单,字段冲突返回业务错误。 +- [x] 订单金额以整数分保存,商品和金额快照创建后不可被应用配置改写。 +- [x] 收银台使用不可猜测 token,桌面端双栏、移动端纵向显示商品、金额、二维码、过期和状态。 +- [x] 微信 Native 下单返回的二维码对应当前本地平台订单号和金额。 +- [x] 支付平台通知 / 查单确认前,客户端轮询或伪造回调不能把订单改为 `paid`。 +- [x] 重复、乱序、错误金额、错误签名、错误应用归属的通知不会重复确认或改变订单。 +- [x] 接入方回调异步投递、签名、重试和失败状态可查询。 +- [x] 个人中心和后台均能按归属和权限查看应用、订单及失败记录。 +- [x] `shared-contracts`、OpenAPI、schema 绑定和契约测试同步通过。 + +## 证据要求 + +2026-10-03 验收复核:支付专用 schema、typed facade、API、应用管理、provider-attempt claim、通知确认、签名回调 outbox、后台投递状态、profile/payment 路由和前端 build 已完成。项目 dev stack 发布后 `GET /healthz` 返回 HTTP 200,live schema 包含 `payment_order` / `payment_webhook_delivery`;真实商户沙箱二维码和真实微信通知因环境未注入商户凭据未验证,按“有可用沙箱时补充”处理。 + +- 自动化:Rust 领域 / API / provider 测试、前端页面与契约测试、schema 检查、OpenAPI 检查、编码和 diff 检查。 +- 运行时:项目 dev stack 发布当前 schema 后,api-server 使用正确端口和 bootstrap 配置,`GET /healthz` 返回 HTTP 200;live schema 已包含 `payment_order` 与 `payment_webhook_delivery`;微信 provider mock 由现有 provider 测试覆盖。 +- 未验证:真实商户沙箱动态二维码和真实微信通知,当前环境没有可安全使用的商户凭据;代码保留 provider 配置失败和支付状态失败关闭语义。 +- 边界:权限、幂等、重放、金额不一致、回调失败、过期、未知通知和敏感日志检查。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f970f10c4..9f35ea21c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,6 +9,51 @@ - 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。 - 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。 + +## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359) + +- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。 +- 决策:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(与生成入口 `start_local_project_asset_generation` 的 `targetCategory` **同一口径**:GUI 完成登记以入口栏目为准);前端 `uploadProjectAssetFilesAndReadSnapshot` 透传该字段,栏目画布工具栏上传取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。取值只接受共享分类枚举,非法值由原生失败关闭,前端不做伪分类。 +- 边界:不传 `targetCategory` 时保持既有 kind 派生行为——资源面板(`ResourceCanvasPanelView`,跨栏目列表而不是栏目工具)、UI 编辑器图片导入(`import_ui_editor_local_files`)、聊天附件上传都不变。`upload_local_asset_at` 签名保持不变(委托到新的 `upload_local_asset_at_with_category`),既有约 25 处调用点与 Rust 单测零改动。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{assets.rs,commands/desktop.rs}`、`apps/ai-game-creator-shell/src/view/project-development/{projectResourceLiveUpdateModel.ts,index.tsx}`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、PRD §3.10、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 载荷表)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a)、`pitfalls.md`。 +- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell assets::tests`(新增 `upload_registers_into_the_explicit_entry_category`:显式栏目 → `character`、不传 → `unclassified`、非法 `version` 失败关闭且不新增登记);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(196 passed / 9 skipped,含新增「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-10-03 生成绑定不再经 prettier:ts-rs 原始输出即提交形态 + +- 背景:`scripts/check-generated-bindings.mjs` 对 AGC 的 `chat/generated` / `services/generated` 在重生成后会就地跑一遍 `npx prettier --write` 再比较。这会直接改写生成文件,还把「Rust 声明真的变了」与「prettier 版本 / 配置造成的格式漂移」混在同一条告警里——本次报出的 `ThreadRequestKind.ts` / `TurnCompletedStatus.ts`「内容变化」无法复现为语义变化(已提交内容与当前 Rust 枚举一致),prettier 归一化把格式差异也报成了「与 Rust 声明不一致」;生成物被仓库格式化工具二次改写后,重跑 `cargo test export_bindings` 也不再幂等。 +- 决策:生成绑定一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写。`check-generated-bindings.mjs` 删掉 prettier 步骤,直接逐字节比较原始输出;`packages/shared/src/contracts/generated`、`chat/generated`、`services/generated`、`features/ui-editor/types` 四个目录统一登记进 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`;`.prettierrc.json` 里为 `contracts/generated` 设的 `printWidth: 1000` / `singleQuote: false` 覆盖随之删除。 +- 边界:ts-rs 原始输出在多行对象 / 枚举变体行尾带空格,`.gitattributes` 对四个生成目录设 `whitespace=-trailing-space`,CI 的 `git diff --check` 不再误报;手写文件的行尾空白检查不变。生成 `.ts` 仍提交进仓库供前端消费,并标 `linguist-generated=true`。 +- 影响范围:`scripts/check-generated-bindings.mjs`、`.prettierignore`、`.prettierrc.json`、`.eslintrc.cjs`、`.gitattributes`、`apps/ai-game-creator-shell/src/{view/project-development/chat,services}/generated/**`。 +- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`、`npm run check:generated-bindings`(shared-contracts 1 + AGC 104 = 105 个文件)、`npx prettier --check` 四个生成目录(全部跳过)、`npm run check:encoding`、`git diff --check`。 + +## 2026-10-03 回合错误的处置分类层与线程条目脱敏边界 + +- 背景:`TurnError::terminal_failure -> Option` 用一个 `None` 同时表达"没有失败"和"不是失败、要继续跑(返修 / 复核控制流)",调用方看到 `None` 只会理解成前者,`dispatch.rs` 两处只能 `.expect("回合失败必可投影成失败载荷")`;同时 ThreadManager 在搬运线程条目时做字段级脱敏与限长,前端要被截断,而且与失败载荷的脱敏是两套实现。 +- 决策(分类层):`TurnError::classify(&self, root) -> TurnErrorClassified{ShouldStop(TurnFailure), ShouldContinue { detail: String }}` 取代 `terminal_failure`——真失败继续投影成 `TurnFailure`(脱敏 + 截断仍在这一处),控制流带自己的说明走 `ShouldContinue`;`dispatch.rs` 两处改成按分类 match,`ShouldContinue` 不写终态、不伪造失败。"这一轮怎么收场"仍只由 `TurnCompletion` 定义,private `SessionOutcome` 并入 `session_completion`。 +- 决策(脱敏边界):ThreadManager **不做任何字段级脱敏与限长**——`wire/items.rs` 的 `bounded` / `detail_text` / `sanitize_detail_text` / `relativize_project_root_paths` / `thread_delta_text` 与三个字符上限常量删除,条目与流式增量原样透传;脱敏移到前端 `chat/conversation/directThreadSanitize`,在 bootstrap / consume / 历史切片进入聊天状态之前统一做(前端手里有 `projectPath`,能把项目内绝对路径归一成相对路径)。失败载荷(错误文案)的脱敏不属于 ThreadManager,保留在 `TurnError::classify` 投影时。订阅侧的 8 MiB / 8192 事件缓冲上限不动:那是背压,不是字段限长。 +- 边界:错误尚未持久化,`TurnErrorClassified` 是纯宿主内部类型、不加 `Serialize` / `TS`;线上 `TurnFailure` 与 `ThreadEvent` 形状、占位符词表(`` / `[redacted-secret]` / `[redacted-sensitive-field]` / `[redacted-config]` / `[redacted sensitive context]`)都不变。`TurnFailure` 名字加了 `// TODO badnaming` 待后续改。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{turn_error.rs,mod.rs},thread_manager/{dispatch.rs,turn_completion.rs,wire/{items.rs,failure.rs,tests.rs}},generation/prompt_context.rs}`、`apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs`、前端 `chat/{controller/useDirectThreadChatSubscription.ts,conversation/directThreadSanitize.ts}`、`chat/generated/{ThreadItem,TurnFailure}.ts`、`tests/directThreadSanitize.test.ts`。 +- 验证:`cargo test -- agent:: --skip export_bindings`(949 passed;删掉一条已无对象的流式脱敏用例)、`npm run check:generated-bindings`、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts`(12 passed)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`;全量 vitest 的 `gameDistributionPublish*` / `recentProjectsHook` localStorage 失败为既有问题。 + +## 2026-10-02 DirectProject 失败载荷两臂改名与 typed 失败原样下发 + +- 背景:`turn.completed.failure` 的两个载荷臂把字符串债藏在像正常类型的名字后面(`TurnFailed { stage, detail: String }`、`TurnFailedUnclassified { detail: String }`);同时 `direct_runtime` 与 `thread_manager::dispatch` 各有一处收口把**任意** `TurnError` 重包成 `TurnFailed { stage: turn_failure_stage(), detail: 预拼收口文案 }`,typed 变体(模型调用 / 超时 / 通道断开)在下发前就被吃掉,前端只能看到 catch-all 与一个伪造的 `code-generation` 阶段。 +- 决策(命名):两个载荷臂按"这是待清债"的既有约定起丑名字——`TurnFailed → SuperErrorFromStringPlusStage`(只有 `stage` 是 typed、`detail` 仍是产生层字符串)、`TurnFailedUnclassified → Unclassified`(连阶段都没有);线上 `type` 同步为 `superErrorFromStringPlusStage` / `unclassified`,生成文件 `TurnFailed.ts` / `TurnFailedUnclassified.ts` 删除。`stage` 留在变体内部,**不做成 wrapper 层的正交字段**:其余 8 个变体根本没有阶段,包到外层只会给它们编假值。 +- 决策(透传):删掉上述两处收口重包,`Err(failure)` 原样透传;`.agent/runtime/errors`、应用日志与错误上报池的审计写盘保留,只是返回值不再当失败载荷的 `detail`。前端因此第一次能按真实变体(`modelCallFailed` / `timedOut` / `transportClosed`…)选文案。 +- 边界:不改 `SuperErrorEnumFromStringTyped`(仍在 `is_retryable` / `is_model_repairable` 上做内部分类,detail 的 typed 化留给上游改造);不迁就存量字符串分流、不加别名;错误尚未持久化,线上 `type` 值直接改、不做迁移。 +- 影响范围:`agent/codex_app_server/turn_error.rs`、`agent/direct_runtime/{mod.rs,user_input.rs}`、`agent/thread_manager/{dispatch.rs,turn_completion.rs,wire/failure.rs}`、前端 `chat/{conversation/directTurnFailure.ts,generated/**}`、`src/features/agent-runtime/model.ts`(注释)、`tests/directThreadChat.test.ts`。 +- 验证:`cargo check`(bin)、`cargo test -- agent:: --skip export_bindings`(948 passed)、`cargo test export_bindings` 后 `npx prettier --write chat/generated/*.ts`、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`(`NODE_OPTIONS=--localstorage-file=…`,196 files / 1930 passed)、`npm run check:encoding`、`git diff --check`。 + +## 2026-10-02 DirectProject 回合错误命名化与 wire 模块拆分 + +- 背景:DirectProject 三条错误通道的类型还带着 `Direct` / `DirectCodex` 前缀(`DirectTurnError` / `DirectTurnEnqueueError` / `DirectTurnFailure`),且 `thread_manager/wire.rs`(1600+ 行)把条目投影、事件、失败载荷与终态判定混在一个文件里;`direct_turn_error.rs` 也留在 `agent/` 顶层而不是它服务的 `codex_app_server` 深模块旁边。 +- 决策(命名):既然类型已经归到具名深模块,去掉冗余前缀——`DirectTurnError → TurnError`、`DirectTurnEnqueueError → EnqueueError`、`DirectTurnFailure → TurnFailure`、`DirectTurnDeadline → Deadline`、`DirectCodexFailureStage → FailureStage`、`DirectCodexNativeKind → NativeKind`、`DirectModelCallKind → ModelCallKind`;`direct_codex_user_item` 里的 `DirectCodexUserItem/UserMessageItem/UserRole/UserContentPart/UserAttachmentReferencePart/UserRuntimeRegionPart → UserItem/UserMessageItem/UserRole/UserContentPart/UserAttachmentReferencePart/UserRuntimeRegionPart`;`codex_app_server` 内部的 `DirectCodexTurnKind/DirectCodexTurnObservation/DirectTurnRunFailure/DirectTurnReport/DirectTurnTerminalContext/DirectTurnCancelView → TurnKind/TurnObservation/RunFailure/TurnReport/TerminalContext/TurnCancelView`;`direct_now_ms → now_ms`。载荷 struct 名仍是"变体裸名",`TurnFailure` 的投影点仍是 `TurnError::classify` 一处。 +- 决策(模块归位):`agent/thread_manager/wire.rs` 提升为目录并按职责拆分——`wire/{mod,clock,items,turn,failure,tests}.rs`,`wire/` 只留**线上形状**(`TurnFailure` 载荷与 `ThreadEvent`);`agent/direct_turn_failure.rs` 的终态判定并入 `agent/thread_manager/turn_completion.rs`;`agent/direct_turn_error.rs`(三条错误表与分类)移到 `agent/codex_app_server/turn_error.rs`。`thread_manager::wire::*` 仍从 `mod.rs` 平铺 re-export,外部路径不变。 +- 决策(终态类型):宿主侧的回合终态从 `struct TurnTerminal { status, failure }` 改为判别联合 `enum TurnCompletion { Completed, Interrupted, Aborted, Failed(TurnFailure) }`——`status` 由变体反推(投影回 `turn.completed` 时才变回字符串),失败必须带载荷、正常收场不许带;「有载荷就一定是失败」这条反推关系不变。`TurnCompletion` **不是线上形状**:不加 `Serialize` / `TS`、不导出、不下发,放在 `thread_manager/turn_completion.rs` 而不是 `wire/`;收尾阶段推出来的 `status` 字符串在这里一次性收进类型,认不出的值 fail closed(按未分类失败),不冒充正常收场。`thread_manager/mod.rs` 的过期回合释放兜底也改走 `TurnCompletion::Aborted.event(...)`。 +- 边界:只改类型 / 函数 / 生成文件命名与模块位置,不改线上 JSON 形状(`TurnFailure` 的 `type` 判别值与字段名不变,`TurnCompletion` 只影响宿主内部);不迁就存量字符串分流,不保留别名或 `Display` 回落。`DirectTurnFailureKind`、`wire_kind()`、`Display for TurnError` 与 `From for String` 已在上一轮删除。 +- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs,turn_error.rs},thread_manager/{mod.rs,dispatch.rs,wire/},direct_runtime/{mod.rs,user_input.rs},direct_codex_user_item/*,runtime_driver/entrypoints.rs,...}`、前端 `chat/{controller,conversation}/**` 与 `chat/generated/**`(旧绑定文件删除、新绑定文件随 `cargo test export_bindings` 生成)、`scripts/check-generated-bindings.mjs`、`tests/**`。 +- 验证:`cargo check`(bin)、`cargo test -- agent::thread_manager`(69 passed)与 `cargo test -- terminal`(73 passed)、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`(DirectProject 相关用例全绿;`gameDistributionPublish*` / `recentProjectsHook` 的 localStorage 失败为既有问题,与本次无关)、`npm run check:generated-bindings`(104 个文件)、`npm run check:encoding`、`git diff --check`。 + ## 2026-10-02 资源编辑远端失败的原始原因穿出到工具错误,资源编辑错误通道补一层 typed - 背景:轮询到 `status=failed` 时客户端只读 `status`,丢掉平台在同一个响应里给的 `error`(契约 `ExternalEditorGenerationJobResponse.error`),统一写 `terminal_failure_code = remote-generation-failed` 并返回「remote-terminal-failed: 资源编辑生成失败」。平台的可行动原因就此消失:模型与用户卡片只看到一句「失败了」,重试路径(`ensure_resource_edit_phase_resumable`)也只有分类码。这违反 `pitfalls.md`「远端资源编辑终态必须指出唯一出口」里已写下的口径——「首次失败的原始拒绝说明继续由当次错误文案承担」;提交期 HTTP 400 分支(`editor_api_rejection_reason`)兑现了,轮询分支没有。另外 `remote-terminal-failed:` 只是文案前缀(全仓没有 `starts_with` 解析它),在第一句失败文案里与「失败」重复。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 182e4656a..c5aa747b5 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -53,6 +53,8 @@ ## 验证路由 +AGC 生成准备只通过单目录 GET 确认当前账号绑定,不能重新依赖完整素材库,也不能把目录查询失败当成首次无绑定而创建目录。目录删除时保留证据并停止;`prepared / accepted / legacy-completed` 生成账本继续用冻结快照恢复。该查询同时覆盖站内会话与 External v1,后端新增接口先发布,旧 API 保持兼容;详见 AGC 实施计划和后端数据契约。 + Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。 AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index b9b691b1f..beca4a1a9 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,13 +2,21 @@ 这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。 +## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目 + +- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。 +- **原因**:上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套;`upload_local_asset` 原先不接受入口栏目,GUI 工具栏上传只能落 kind 派生分类。 +- **处理(现行口径)**:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(非法值失败关闭,不回退 kind 派生);栏目画布工具栏上传时取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。生成入口 `start_local_project_asset_generation` 的 `targetCategory` 是同一口径——GUI 完成登记以入口栏目为准。 +- **判据/取证**:`cargo test --locked ... --bin genarrative-ai-game-creator-shell upload_` 的 `assets::tests::upload_registers_into_the_explicit_entry_category`(显式栏目 → `character`;不传 → `unclassified`;非法 `version` 失败关闭且不新增登记);appSurface「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`。 +- **边界**:资源面板(跨栏目列表)、UI 编辑器图片导入、聊天附件上传都**不带**入口栏目,保持 kind 派生。任何新增的「某个栏目里的上传入口」都必须显式带上当前栏目,否则又会复现本坑。 + ## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报 - **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。 - **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。 - **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。 - **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。 -- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。 +- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物保持 ts-rs 原始输出(不再经 prettier / eslint 二次改写,见本节「生成绑定」口径);ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。 - **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。 @@ -72,6 +80,7 @@ - **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。 - **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 +- **现状(2026-10-03 更新)**:逐 delta 的 Rust 脱敏已整体退役——ThreadManager 改为原样透传,流式正文与历史条目的脱敏统一移到前端 `directThreadSanitize`,在事件进聊天状态前逐段处理并保留换行。上面两条根因与"仍未修"针对的实现已不存在,保留作历史。 ## 2026-10-01 文件列举被无关临时锁删除打断 @@ -5794,7 +5803,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后只恢复 `apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUser*.ts` 的仓库版本,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`;不要恢复整个 `generated/` 目录,以免误删 DirectThread 的现役绑定。绑定与前端形状冲突时以**已提交的 DirectCodexUser 绑定 + 前端**为基准排查。 - **验证**:恢复仓库版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的生成产物;它们不属于前端契约,发现后直接删除,不提交。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`(ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`beforeBuildCommand`)。 -- **2026-09-24 补充(纯格式 churn 的一档)**:直接运行 AGC 的测试二进制(含全量套件,里面带 `export_bindings`)同样会把 `src/view/project-development/chat/generated/DirectThread*.ts` 重写成 raw ts-rs 形态——双引号、行尾空格、`};` 变 `, };`——于是 `git diff --check` 报一片 trailing whitespace,而 `npm run check:generated-bindings`(只覆盖 `packages/shared/src/contracts/generated`)不会报警。处理:对这组文件跑一次 `npx prettier --write "apps/ai-game-creator-shell/src/view/project-development/chat/generated/*.ts"` 做归一化,再确认 `git diff --stat` 为空——为空就说明这一档只是格式 churn,没有语义变化;不要为它盲目 `git checkout` 整个目录。该目录目前没有自动漂移门禁,靠「跑完测试后归一化 + diff 检查」把关。 +- **2026-09-24 补充(纯格式 churn 的一档)**:直接运行 AGC 的测试二进制(含全量套件,里面带 `export_bindings`)同样会把 `src/view/project-development/chat/generated/DirectThread*.ts` 重写成 raw ts-rs 形态——双引号、行尾空格、`};` 变 `, };`——于是 `git diff --check` 报一片 trailing whitespace,而 `npm run check:generated-bindings`(只覆盖 `packages/shared/src/contracts/generated`)不会报警。处理(2026-10-03 更新,口径已变):不再跑 prettier 归一化——`chat/generated` 与 `services/generated` 的提交形态就是 ts-rs 原始输出;两个目录已进 `.prettierignore` + eslint `ignorePatterns`,`git diff --check` 由 `.gitattributes` 的 `whitespace=-trailing-space` 豁免,`npm run check:generated-bindings` 会逐字节比较原始输出。跑完 `cargo test` 后 `git diff` 为空即为幂等。 +- **2026-10-02 补充(过滤跑也躲不开)**:`cargo test` 的位置参数是**子串匹配**,`-- agent::thread_manager::` 这类"只跑几个模块"的过滤会连带命中 `agent::thread_manager::wire::failure::export_bindings_turnfailure`,于是照样重写 `chat/generated/` 与 `src/services/generated/`(后者是 auth 绑定)。要按模块跑又不碰生成目录,用 `cargo test --bin genarrative-ai-game-creator-shell -- agent:: --skip export_bindings`;已经被重写时不要再做 prettier 归一化:两个目录的提交形态就是原始输出,按需重新生成或 `git checkout --`,再用 `npm run check:generated-bindings` 确认无漂移。 ## 2026-09-16 Node 26 下 vitest 的 jsdom 用例拿不到 window.localStorage @@ -6277,7 +6287,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。 - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 - **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须①把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,②用**同一个 id** 下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`(落盘失败按回合失败收口);sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex::assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。 +- **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。 +- **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。 +- **根因 8(MCP 全局单槽位)**:外部 MCP 注册表原本只有一个 `Option`,不同项目的 Claude 回合会互相 abort;一个回合结束时的全局 stop 还可能误停另一个项目的桥。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex::assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。MCP 注册表按 canonical project root 分桶,停止操作再核对 server token,迟到的旧回合不能误停同项目的新桥。sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 -- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 +- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 diff --git a/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png b/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png new file mode 100644 index 000000000..999f6e55c Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/payment-checkout-mobile.png differ diff --git a/docs/technical/assets/payment-service-20261003/payment-checkout.png b/docs/technical/assets/payment-service-20261003/payment-checkout.png new file mode 100644 index 000000000..84956ac72 Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/payment-checkout.png differ diff --git a/docs/technical/assets/payment-service-20261003/profile-payment-login.png b/docs/technical/assets/payment-service-20261003/profile-payment-login.png new file mode 100644 index 000000000..aebcb3ff6 Binary files /dev/null and b/docs/technical/assets/payment-service-20261003/profile-payment-login.png differ diff --git a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md index 1de78d7c0..ab8643343 100644 --- a/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md +++ b/docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md @@ -6,7 +6,7 @@ ## 1. 一句话 -功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 +功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`(带当前栏目 `targetCategory`,素材登记进上传它的那一栏);生成 / 上传成功后一律走既有 manifest 刷新与资源定位。 ## 2. 入口矩阵(事实源) @@ -42,7 +42,7 @@ | 生成 UI 设计图 | 同上 | `kind: 'ui-design'`,默认 `16:9 · 1K`;前置同上 | | 生成背景音乐 | `start_local_project_asset_generation` | `kind: 'background-music'`、`idempotencyKey`;任务 id 即该次生成的 operation id | | 生成音效 | `start_local_project_asset_generation` | `kind: 'sound-effect'`、其余同上 | -| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` | +| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes, targetCategory }`;`targetCategory` = 当前栏目(Issue 359) | `start_local_project_asset_generation` 的完整参数是 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath, idempotencyKey }`(Rust `src-tauri/src/asset_generation_tasks.rs`);图片类入口沿用 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }` 逐字不变,音频入口只带 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`idempotencyKey` = 该次生成的幂等键,任务 id 即 operation id;原生命令内部仍复用既有音频无源生成链路,不新增平台路由与请求体口径),**提交即返回**一条任务记录(`taskId / status / phaseDetail / assetId / error` 等);生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,账本落在项目内 `.agent/runtime/asset-generation-tasks/tasks.json`,进度由 `list_local_project_asset_generations` 读回。截图面板不再「等生成结束」,所以**点「生成」即同步关闭面板**(不等 IPC),面板内不出现阶段文案;只有「点击瞬间就失败」才带草稿重开(关闭 ≠ 取消)。 diff --git a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md index 989095efb..4d84a1e4c 100644 --- a/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md +++ b/docs/technical/【实施计划】DirectProject命令接单化-2026-09-23.md @@ -80,8 +80,8 @@ - `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。 - 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。 -- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --` - 掉不是本次新增的文件。 +- `cargo test export_bindings` 会重写全部 `chat/generated/`;生成目录的提交形态就是 ts-rs 原始输出 + (2026-10-03 起已从 prettier / eslint 排除),跑完用 `git diff` 为空确认幂等,不要做 prettier 归一化。 - 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。 ## review 收口第二轮(2026-09-24) diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 70bbbd2fe..b9c3bb849 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 -- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 @@ -188,13 +188,15 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema ### 环境与工作流 - 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。 -- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 +- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-environment-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。 ### 分层验证与预算 - 复用客户端浏览器与现有固定玩法场景。视觉检查采集双端画面/布局/资源/诊断;玩法检查分别在 desktop/mobile 执行明确的固定场景和真实输入,按视口保存结果。旧报告缺少移动端玩法结果时保持未知,不补通过。报告必须声明检查层级;视觉通过不能宣称玩法通过,固定场景通过也不能宣称覆盖未执行的完整关卡。 +- 浏览器工具回执在 `mode=gameplay` 时新增 `gameplayResults` 双端有界投影,每个视口包含 `viewport`、`passed`、`diagnostics`、`assertions` 及 `initialPhase` / `initialSequence` / `initialLevel`、`finalPhase` / `finalSequence` / `finalLevel`。`assertions` 仅列当前固定场景的断言名称和通过状态;每端最多公开 4 条诊断,每条最多 512 字符。缺少某端结果时仍返回该视口 `passed=false`,诊断说明证据缺失,断言为空且六个状态字段为 `null`;断言的 `passed=false` 可包含未执行,不能据此断言该断言已独立执行且失败。`mode=visual` 的 `gameplayResults` 为空,并在摘要中明确玩法未执行。摘要从同一投影给出每端首条诊断和首个未通过断言,不能与结构化结果相矛盾。工具回执不得展开完整宿主报告;`reportPath` 只作宿主证据引用。宿主报告结构、验收门禁、持久化预算和私有路径保护保持原合同,旧回执不回填新字段。 +- 回执验收覆盖 start 禁用、点击后 phase 不符合和玩法成功;逐视口核对持久报告与摘要中的状态、首条诊断和首个未通过断言,并检查成功、失败两条最终 MCP 回包路径。 - 输入或碰撞改变先做定点玩法检查;纯图像/颜色变化做视觉检查;首次交付和影响闭环的修改做所需玩法验证。新增失败或相关代码变化才重跑对应层,不因改说明文字重复完整验证。 - 内置试玩和客户端托管的外部 Node/npm 验证共用当前 clientTurnId 的持久化预算。客户端分配递增执行序号,模型提供的旧 attempt 仅作兼容输入,不能减少计数或重置预算;同一轮错误反馈、工具切换和进程重启均不能刷新已消费次数。 - 新增本地 validation.maxRuns(默认 3,正整数)独立于 llm.maxRetries;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。 @@ -329,7 +331,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation - 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。 - 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。 - 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。 -- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。 +- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。GUI 上传(栏目画布底部工具栏的 `upload_local_asset`)与图片类生成**同一口径**:带可选 `targetCategory` 在当前栏目登记,不传(资源面板、UI 编辑器导入、聊天附件)时保持按内容证据派生 kind 的行为。 - 生成面板打开后,占位和面板需处于当前画布标题栏与底部工具栏之间;面板复用公共外观,空间不足时面板内部滚动,提交按钮可达。音频/BGM 占位绑定原有 operation/idempotency 身份,进行中不允许换身份重复提交;失败可用原身份重试,成功结果与图片一样接管占位。关闭未提交浮层保留可继续编辑的草稿,删除占位才丢弃该草稿。 - 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 @@ -946,6 +948,10 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 - External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。 - binding 的 lookup key 与稳定 payload 内容指纹分开校验;远端 project/folder/resource/object ID、object key 或尺寸被单独改写时必须失败关闭。同一本地项目和 principal 的首次项目 binding 建立必须串行化覆盖“复读 binding → 必要的远端创建 → 原子安装 binding”,不得因并发任务创建孤儿画布。External v1 项目、素材目录和项目资源创建请求携带由 binding key 派生的稳定 `Idempotency-Key`;服务端在 owner 与接口命名空间内生成稳定 ID,并允许同键同正文安全重试,使远端响应后、本地 sidecar 落盘前崩溃也不会重复创建。 +- 素材生成准备的目录确认采用单目录读取:站内 `GET /api/editor/assets/folders/{folderId}` 与开发者 `GET /api/external/v1/editor/assets/folders/{folderId}` 返回现有 `{folder: ...}` 数据结构,后端按目录主键读取并校验 owner,不读取账户素材、不创建默认目录、不执行媒体修复。External 入口要求 `editor:asset` scope;缺失目录返回 404,越权返回 403,未认证返回 401。原素材库快照及目录创建、修改、删除接口的合同不变;不修改持久化表结构。先部署新增后端接口,再发布依赖该接口的客户端;新客户端不回退完整素材库。 +- 已有当前账号项目 binding 或恢复 partial 的目录身份时,在新的生成准备中按该 ID 确认目录并核对响应 ID;合法空目录也可继续。首次没有可复用目录身份时直接沿用稳定幂等创建和冻结正文,不先列举素材库,不按同名认领目录。partial 继续只承载中间恢复进度,成功写入完整 binding 后删除;既有生成账本的 `prepared / accepted / legacy-completed` 恢复仍使用冻结快照,不重新查询或创建目录。 +- 单目录查询超时、断网、服务异常、鉴权失效、越权或会话切换时停止准备并保留现有 binding / 恢复证据,不解释为目录缺失、不自动 POST 新目录。明确 404 时提示绑定目录不存在并停止,不用原键复活目录、不换键重建或重发生成;自动重新绑定与素材分页、关键词筛选、按素材 ID 导入不属于该行为。验证覆盖全库不可用而单目录正常、空目录、首次建绑、缺失与越权、查询失败及切号,并保留并发单次创建、创建响应丢失、冻结正文及生成账本恢复回归。 +- 单目录合同的定向验证使用 AGC Rust `external_canvas_context`、`canvas_generation_tests`、`project::resource_editor::tests`、`background_removal_tests` 与 `direct_tools_mcp::tests`,工具链 HTTP 夹具按单目录 GET 返回 `data.folder`;后端使用 `external_editor`、`external_mcp` 和 `protected_route_matrix_keeps_auth_methods_and_head_behavior`,覆盖 OpenAPI 自动增加单目录 MCP 工具后的目录数量及名称唯一性。`npm run check:editor-idempotency-procedures` 在独立 SpacetimeDB 中验证真实目录读取、归属、删除与只读行为,host 单测不能代替该运行时证据。 - 当前 binding wire schema 为 v2,payload fingerprint 必填。已落盘的 v1 project/resource JSON 只能经独立严格 wire 结构读取,在 lookup key、principal、本地 source、远端字段、对象键、时间和尺寸等不变量全部通过后重算指纹并原子回写 v2。不得用 `serde(default)` 把未知或篡改文档混入迁移。 - 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。 - 远端提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前复验冻结会话;同步本地 commit 持有冻结会话租约,使切号只能发生在完整安装前或安装后。Tauri 手工生成同样使用稳定的 durable operation slot,不得使用进程内临时幂等键。历史资源编辑账本从 manifest 远端 resource ID 迁移到本地 asset ID 时,只允许在本地 asset、路径和内容摘要仍一致时兼容旧指纹,新请求不得恢复信任 manifest 远端 ID。 diff --git a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md index 08df2cc5f..07b6c5b88 100644 --- a/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md +++ b/docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md @@ -57,7 +57,7 @@ | **S15** 删除 | 工具条 / 标签面板「删除」→ 确认弹窗 | 三分支:① 无引用→直接删;② 被引用未勾选→只删素材、版本保留**悬空绑定**、界面不合成幽灵资源卡;③ 勾选→素材与该批版本在**同一次 manifest 写入**内一起删 | L413(版本只追加的唯一例外)、L532–533;#309 C5 | 弹窗 `ariaLabel="确认删除资源"`;被引用时出现 `被 N 个游戏版本使用` + 版本列表 + checkbox `把相关游戏版本一并删除`;无引用时该 body 整块不渲染 | **只摘登记、不删磁盘文件**;读引用命令精确名是 `read_local_project_asset_references` | | **S15a** 替换素材(面板内选 + 画布点选目标) | 选中被**当前版本**绑定的素材 → 工具条「替换素材」→ 画布右上角出现非模态候选面板(面板内可筛可选)→ 直接在画布上点目标素材(或点面板里的候选)→ 面板「确认」 | 面板确认与画布点选是**同一条**写入链路(`replace_local_project_version_resource`,载荷逐字一致):改该版本绑定、不建新版本;画布点选只把目标落成面板的当前选择,确认后才写入 | PRD §5.3 / §7.8 第 8 条;#309「直接替换」口径 | 面板判据:`role="dialog"` 名为「选择替换素材」、挂 `.image-canvas-editor__project-asset-picker--floating`、**没有** `.platform-overlay` 遮罩;点画布候选后面板里该候选 `aria-selected="true"`、面板里的搜索词与分类筛选保持原样、零写入;非法目标(源素材本身 / 不在权威候选里 / 分类不同 / 未登记资源)在面板里出现 `role="alert"`(「替换素材与源素材相同」/「替换素材未登记或已被删除」/「替换素材不兼容:分类不同」)且零写入;面板关闭 = 卸载;Esc 只收面板(画布全局 Esc 的清选中不随之触发) | 点选只能点**当前画布上可见**的卡:目标在别的栏目时先切栏目(替换会话跨栏目存活,不因「收起资源」或切栏目结束);面板开着时空白处仍可框选 / 平移,卡片单击语义的恢复发生在面板关闭之后 | -| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset`;生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | +| **S11a** 栏目画布底部工具栏 | 进「UI 交互 / 角色与对象 / 场景与环境 / 音频」任一栏目 → 点左下角工具栏里的入口生成 → 「收起资源」回总览看工具栏消失 | 工具栏只在矩阵四个栏目(功能画布)渲染;图片类入口走 `start_local_project_asset_generation`(提交即返回、生成在后台跑),音频入口走同一条命令的音频载荷,上传复用 `upload_local_asset` 并带上当前栏目 `targetCategory`(素材落在上传它的那一栏,不落「待归类」);生成 / 上传成功后走既有 manifest 刷新与资源定位 | PRD §3.10、§7.9 | DOM 判据 `[data-resource-bottom-toolbar=""]`(资源总览、「所有资源」展开态、文档、待归类、项目版本都**没有**这个节点);工具栏是 `.game-resource-book-manager` 的直接子节点(`closest('.game-resource-book-scene')` 为 `null`);每个入口一次 `start_local_project_asset_generation`:图片类载荷逐字为 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }`(`kind` 映射见 PRD §7.9 第 3 条;`taskId` 是前端每次提交新铸的本地任务 id),音频类载荷为 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`taskId` 即该次生成的 operation id,不发图片类那套比例 / 尺寸 / 参考 / 落点参数);该命令提交即返回,之后有 `list_local_project_asset_generations` 轮询与 `get_local_game_project_revision` + `get_local_game_manifest` 的配对读;点「生成」即把这次输入交给后台账本并**同步关闭**面板(不等 IPC、不等排队、不等生成,画布立即恢复可交互;面板 DOM 里没有阶段文案与「后台运行并关闭」这类在途按钮),关闭不等于取消,关闭后任务仍出现在「生成任务」面板(入口按钮 `aria-label="生成任务"`,非模态浮层、无 `aria-modal`)并显示后端 `phaseDetail`;第一条未终态时提交第二条 → 第二条显示「排队中。」且生成提交 IPC 次数仍为 1,第一条终态后自动补发(次数变 2);缺 `assets/art-spec.png` 时「生成图标素材 / 生成 UI 设计图」仍可点击(`aria-disabled="true"` 但**不是**原生 disabled)并给出含该路径的 `role="alert"` 原因、零生成请求;音频入口面板标题即「生成背景音乐」/「生成音效」且没有类型选择器 | ① 本地通道没有 `model` / `specType` / `replaceExisting` 入参:面板不渲染模型选择器,角色规范与自定义规范共用 `spec` 通道(靠 `assetName` 与提示词区分),「图标规范」在项目已有权威规范图时不再指向 `assets/art-spec.png`(否则会被 Rust 的防覆盖校验硬拒);② 本轮不做生成视频 / 宣发素材 / 生成游戏场景 / 选择工具 / 抓手工具;③ 既有「生成素材」浮层入口只保留生成视频,音频入口只在音频栏目工具栏出现 | ### D 阶段 · 版本与运行 diff --git a/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md b/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md new file mode 100644 index 000000000..5083fc382 --- /dev/null +++ b/docs/【使用说明】支付服务接入与收银台使用说明-2026-10-03.md @@ -0,0 +1,310 @@ +# 支付服务接入与收银台使用说明 + +> 适用版本:2026-10-03 支付应用与订单收银台里程碑 +> +> 本说明面向三类使用者:平台账户管理员、接入平台支付的游戏或外部产品开发者、负责查看订单与回调的后台运营人员。 + +## 1. 服务范围 + +平台提供一套统一支付服务:接入方提交商品、订单和金额,平台使用已配置的微信支付商户号创建 Native 支付订单,并返回网页收银台地址与动态二维码。用户在网页中扫码付款,平台收到微信支付结果后更新订单,再向接入方配置的回调地址通知。 + +当前里程碑的边界如下: + +| 能力 | 当前状态 | +| --- | --- | +| 微信 Native 扫码支付 | 已支持 | +| 游戏客户端或外部产品接入 | 已支持,统一使用 External v1 API | +| 网页收银台 | 已支持,桌面端双栏、移动端上下布局 | +| 订单记账、查单、回调通知 | 已支持 | +| 后台订单与回调投递记录 | 已支持 | +| 自动分账、自动提现、人工结算工作流 | 本里程碑不包含,暂由平台人工处理 | +| 支付宝、退款、周期扣款 | 本里程碑不包含 | + +金额以整数分传递,默认币种为 `CNY`。生产环境必须使用平台已经开通接口的微信支付商户号;前端和外部产品都不能直接持有商户证书或调用微信支付接口。 + +## 2. 一次完整支付流程 + +1. 平台账户管理员在“我的 → 支付服务”创建一个支付应用。 +2. 平台生成该应用的支付 API Key。完整 Key 只在创建成功时显示一次,应立即复制到接入方服务端的密钥管理系统。 +3. 外部产品服务端使用 API Key 和 `Idempotency-Key` 创建订单。 +4. 平台服务端向微信支付商户号下单,返回 `checkoutUrl` 和 `providerQrCode`。 +5. 外部产品将用户跳转到 `checkoutUrl`,或在自己的客户端中打开该网页。 +6. 用户使用微信扫描收银台二维码付款。 +7. 微信支付通知平台,平台校验订单号、金额和支付状态后将订单改为 `paid`。 +8. 如果应用配置了成功回调地址,平台通过回调投递器通知外部产品;外部产品再按自己的业务规则发放道具、虚拟币或权益。 +9. 外部产品遇到网络超时或回调延迟时,使用查单接口恢复订单状态,不要换一个幂等键重新下单。 + +## 3. 创建和管理支付应用 + +### 3.1 打开入口 + +登录平台后进入“我的”,选择“支付服务”。未登录时会看到登录入口: + +![支付服务未登录入口](technical/assets/payment-service-20261003/profile-payment-login.png) + +图 1:支付服务入口截图。截图来自本地前端预览环境,未登录状态不会读取或展示任何支付应用。 + +### 3.2 创建应用 + +在“创建接入应用”中填写: + +| 字段 | 填写规则 | +| --- | --- | +| 应用名称 | 用于区分游戏或外部产品,例如“星港商城”或“我的游戏支付” | +| 支付成功回调地址 | 可选。建议填写接入方服务端的 HTTPS 地址,例如 `https://game.example.com/payment/callback` | + +回调地址必须使用 HTTPS;本地开发可以使用 loopback HTTP 地址。不能包含用户名、密码或 URL fragment。保存后平台会创建应用和默认支付 API Key。 + +### 3.3 保存 API Key + +创建成功后页面会显示完整 API Key,并提示“完整 API Key 只显示这一次”。操作要求: + +1. 立即点击复制按钮。 +2. 只保存到接入方服务端的环境变量或密钥管理服务。 +3. 不要提交到 Git、客户端安装包、网页源码、截图或工单。 +4. API Key 泄露时,在“支付 API Key”区域撤销旧 Key,再创建新的支付应用 Key。 + +页面只展示 Key 前缀、所属应用、权限、创建时间、最后使用时间和撤销状态,不再次展示完整密钥。 + +### 3.4 停用应用和撤销 Key + +- “我的支付应用”中的“停用”会阻止该应用继续创建支付订单;历史订单和后台记录保留。 +- “支付 API Key”中的“撤销 Key”会使该 Key 立即失效;已经创建的订单不因撤销 Key 自动取消。 +- 更换 Key 后,必须同步更新接入方服务端配置,并保留一次查单验证。 + +## 4. 外部产品接入 + +### 4.1 公共地址和鉴权 + +以部署域名作为 API 根地址,以下示例中的 `$BASE_URL` 替换为实际地址: + +```text +$BASE_URL/api/external/v1/payment/orders +``` + +请求头: + +```http +Authorization: Bearer tnr_pay_xxxxxxxxxxxxxxxxx +Content-Type: application/json +Idempotency-Key: game-order-20261003-000001 +``` + +支付 API Key 的格式前缀为 `tnr_pay_`。`Idempotency-Key` 要求为 1-128 个可打印 ASCII 字符且不能包含空格;它代表一次业务下单意图。网络结果不确定时,必须复用原值。 + +### 4.2 创建订单 + +```bash +curl -X POST "$BASE_URL/api/external/v1/payment/orders" \ + -H "Authorization: Bearer $PAYMENT_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Idempotency-Key: game-order-20261003-000001" \ + -d '{ + "merchantOrderId": "game-order-20261003-000001", + "title": "星港商城 - 600 水晶", + "items": [ + {"sku": "crystal-600", "name": "600 水晶", "quantity": 1} + ], + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native" + }' +``` + +请求字段: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `merchantOrderId` | 是 | 接入方自己的订单号;建议使用可追踪的 ASCII 字符串,微信 Native 外部订单号最终不超过 32 个字符 | +| `title` | 是 | 收银台展示的商品或订单标题 | +| `items` | 否 | 商品明细,会被原样记账;不参与平台金额计算 | +| `amountCents` | 是 | 应付金额,整数分;例如 `19600` 表示人民币 `196.00` | +| `currency` | 否 | 当前使用 `CNY` | +| `provider` | 否 | 当前使用 `wechat_native` | + +金额由服务端订单快照决定。客户端传来的商品明细不能作为最终收款金额,接入方应在自己的服务端完成价格校验后再调用创建订单。 + +### 4.3 创建响应 + +成功响应为 HTTP `200`,核心字段如下: + +```json +{ + "order": { + "orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a", + "merchantOrderId": "game-order-20261003-000001", + "title": "星港商城 - 600 水晶", + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native", + "status": "paying", + "checkoutToken": "pay_xxxxxxxxx", + "checkoutUrl": "/pay/pay_xxxxxxxxx", + "providerQrCode": "weixin://wxpay/bizpayurl?...", + "expiresAt": "2026-10-03T07:05:00Z" + } +} +``` + +接入方应优先使用完整部署域名拼接 `checkoutUrl`,例如 `https://www.example.com/pay/pay_xxxxxxxxx`。不要把 `providerQrCode` 直接当作支付成功依据;它只是动态二维码内容,支付状态仍以平台订单为准。 + +### 4.4 查询订单 + +```bash +curl "$BASE_URL/api/external/v1/payment/orders/6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a" \ + -H "Authorization: Bearer $PAYMENT_API_KEY" +``` + +建议在收银台打开后每 3-5 秒查询一次,直到状态进入终态: + +| 状态 | 含义 | 接入方动作 | +| --- | --- | --- | +| `pending` | 已创建,尚未完成支付平台下单 | 继续查单;如长时间不变,查看后台或服务日志 | +| `paying` | 已拿到支付平台二维码,等待用户付款 | 展示收银台并等待通知或查单 | +| `paid` | 已确认到账 | 只在此状态发放游戏商品或权益 | +| `expired` | 订单超过有效期 | 不发货,提示用户重新下单 | +| `closed` | 订单已关闭 | 不发货 | +| `refunded` | 已退款 | 按接入方规则撤销或冻结权益 | + +同一个 `Idempotency-Key` 携带相同请求体会返回首次订单;同一个 Key 携带不同请求体会返回 `409`。遇到连接超时,不要生成新 Key 盲目重试。 + +## 5. 网页收银台 + +外部产品拿到 `checkoutUrl` 后,可以: + +- 游戏客户端使用系统浏览器或内嵌 WebView 打开。 +- Web 产品使用新窗口、弹窗或当前页面跳转。 +- 自己展示商品说明,但最终金额应以平台收银台显示金额为准。 + +桌面端收银台左侧展示订单号、商品标题、付款提示和等待状态,右侧展示应付金额、微信 Native 动态二维码和支付渠道: + +![支付收银台桌面版](technical/assets/payment-service-20261003/payment-checkout.png) + +图 2:支付收银台桌面版。此图使用浏览器会话内的模拟订单 `测试游戏道具 / ¥196.00`,用于说明布局,不对应真实商户订单。 + +移动端会自动改为上下布局,用户可以在较窄屏幕中滚动查看金额、二维码和支付说明: + +![支付收银台移动版](technical/assets/payment-service-20261003/payment-checkout-mobile.png) + +图 3:支付收银台移动版。二维码内容由页面的 QR 组件根据模拟支付链接生成;生产环境二维码由微信支付商户接口返回。 + +收银台的支付提示: + +1. 用户必须完全按照页面显示金额付款,少付或多付都可能无法自动确认。 +2. 付款后页面会轮询订单并自动更新状态,但接入方仍应以服务端查单或回调为最终依据。 +3. 订单过期后不要继续使用旧二维码,应重新创建订单。 + +## 6. 支付成功回调 + +### 6.1 平台支付通知 + +微信支付通知地址由平台环境变量 `WECHAT_PAYMENT_NOTIFY_URL` 配置,生产环境必须是外网可访问的 HTTPS 地址,例如: + +```text +https://www.example.com/api/payment/wechat/notify +``` + +该地址接收微信支付商户通知。平台会校验微信通知签名、商户订单号、金额、支付状态和交易号,校验通过后才把本地订单改为 `paid`。 + +### 6.2 外部产品回调 + +如果创建支付应用时填写了“支付成功回调地址”,平台会向该地址投递 JSON: + +```json +{ + "event": "payment.paid", + "orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a", + "appId": "payapp_xxxxxxxxx", + "merchantOrderId": "game-order-20261003-000001", + "amountCents": 19600, + "currency": "CNY", + "provider": "wechat_native", + "providerTradeNo": "4200000000000000000000000000", + "status": "paid", + "paidAt": "2026-10-03T07:01:12Z" +} +``` + +回调请求头: + +```http +X-Genarrative-Signature: sha256= +X-Genarrative-Event: payment.paid +X-Genarrative-Delivery-Id: delivery_xxxxxxxxx +``` + +首期签名规则为:使用创建该支付应用时生成的 API Key 哈希作为 HMAC 密钥,对原始请求体字节执行 `HMAC-SHA256`,并将结果编码为小写十六进制;请求头值为 `sha256=` 加十六进制结果。接入方必须使用原始 body 验签,不要先反序列化再重新序列化。独立可轮换的 callback secret 属于后续安全增强,不要在当前版本按未实现字段接入。 + +回调处理要求: + +1. 先验签,再按 `orderId` 或 `merchantOrderId` 查询自己的订单。 +2. 对 `deliveryId` 和业务订单号做幂等处理,重复通知返回成功,不重复发货。 +3. 成功处理后返回任意 HTTP `2xx`,建议返回 `204`。 +4. 非 `2xx` 会进入平台回调重试;平台最多尝试 8 次,最终失败会出现在后台“外部回调投递”表中。 + +## 7. 后台查看订单和回调 + +管理员进入后台,在侧边栏选择“支付订单”,或打开后台路由 `#payment-orders`。页面分为两张表: + +### 7.1 外部回调投递 + +用于确认平台是否成功通知了接入方,包含:投递 ID、订单号、应用、回调地址、投递状态、尝试次数、最近错误和更新时间。 + +常见状态: + +- `pending`:等待投递或等待下一次重试。 +- `delivered`:接入方返回了成功状态码。 +- `failed`:达到重试上限,需人工排查回调地址、验签和接入方服务状态。 + +### 7.2 订单记录 + +用于查看外部订单号、平台订单号、应用、商品标题、金额、渠道、订单状态、创建时间和支付时间。金额显示为币种和两位小数,订单状态以服务端记录为准。 + +本阶段后台用于收款记账和通知核对,不提供自动给外部产品打款或分账按钮。需要结算时,按订单记录和商户账单人工处理。 + +## 8. 生产配置清单 + +部署前由后端运维配置,不要把下列值写入前端: + +```text +WECHAT_PAY_ENABLED=true +WECHAT_PAY_PROVIDER=real +WECHAT_PAYMENT_NOTIFY_URL=https://<公开域名>/api/payment/wechat/notify +``` + +同时配置现有微信支付 V3 商户信息、商户证书、私钥、平台证书或平台证书密钥等环境变量。真实商户配置必须通过部署系统的密钥注入完成。`WECHAT_PAY_PROVIDER=mock` 只用于本地开发和页面联调,不能用于生产收款。 + +上线前至少验证: + +1. 使用真实商户沙箱或小额测试单创建 Native 订单。 +2. 微信扫码后平台订单能变为 `paid`。 +3. `WECHAT_PAYMENT_NOTIFY_URL` 能收到并确认微信通知。 +4. 接入方回调能通过验签,并且重复回调不会重复发货。 +5. 后台订单表和回调投递表都有对应记录。 + +## 9. 常见问题 + +| 现象 | 优先检查 | +| --- | --- | +| 返回 `401` | Authorization 是否是支付应用的 Key;Key 是否已撤销;是否误用了普通 External API Key | +| 返回 `403` | 支付应用是否已停用;Key 是否具备订单创建或查询 scope | +| 返回 `409` | 是否复用了已有 `Idempotency-Key` 但请求体不同;网络超时场景应复用原请求体和原 Key | +| 返回“缺少 `WECHAT_PAYMENT_NOTIFY_URL`” | 生产真实 provider 必须配置公开 HTTPS 通知地址;本地 mock 才使用默认 loopback 地址 | +| 一直是 `pending` | 查看微信下单是否成功、后台订单是否有 provider 码、部署日志和商户配置;不要直接给用户发货 | +| 用户已付款但接入方未发货 | 先调用查单接口;再看后台“外部回调投递”的状态和最近错误;必要时人工补偿,但要保留幂等记录 | +| 二维码无法识别 | 检查是否把 `providerQrCode` 原样交给 QR 组件;不要对内容做 URL 编码或截断 | +| 本地截图能显示、生产无二维码 | 截图使用的是 mock 数据;生产必须确认微信商户接口返回了 `code_url`,并检查支付服务配置 | + +## 10. 安全与责任边界 + +- API Key 只能放在外部产品服务端,不能放进游戏客户端、浏览器 localStorage 或公开仓库。 +- 客户端传回的“支付成功”事件只能作为 UI 提示,不能作为发货凭据。 +- 发货必须由外部产品服务端根据平台查单或验签回调执行,并以订单号做幂等。 +- 不要记录完整 API Key、微信商户私钥、微信通知原文中的敏感字段或用户支付凭据。 +- 截图中的订单号、金额和二维码均为演示数据;生产环境会由后端订单和平台微信商户接口动态生成。 + +## 11. 相关契约 + +- [外部产品支付服务接入技术方案](./【技术方案】外部产品支付服务接入-2026-10-03.md) +- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json) +- [支付应用与订单收银台里程碑](./project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 3010bab32..dfeb1d2da 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -59,7 +59,7 @@ npm run check:server-rs-ddd `spacetime-client` 的 Cargo `lib.path` 指向 `src/active.rs`,现役 mapper 聚合入口是 `src/active/mapper.rs`;原旧 facade 和 mapper 已删除。 -当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。 +当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`payment.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。 ## API 路由分组 @@ -451,6 +451,30 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。 - LLM Router 计费:读取 Router 用户 `used_quota`,以 `50000 quota = 1 泥点` 结算(`500000 quota = 1 USD`,美元数值直接乘 10,不乘汇率)。上游调用前建立首次基线并补结算,成功响应后同步;不足整数部分、扣费失败和余额不足未支付部分留到后续累计同步。扣钱包、写 `llm_router_consume` 流水与推进 checkpoint 在同一事务完成;流水展示“LLM 调用消耗”。历史费用不追扣。详细契约见 `technical/【技术方案】LLM累计额度结算-2026-09-05.md`。 - 2026-09-05 修订:`/api/llm/responses` 与 `/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。 + +### `payment_app` + +- Rust 结构体:`PaymentApp`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:平台账号创建的外部支付应用,保存应用归属、展示名称、回调地址、启用状态和更新时间。应用使用平台统一商户主体,不保存支付平台私钥。 +- 索引:`by_payment_app_owner_user_id` 用于个人中心应用列表。 + +### `payment_api_key` + +- Rust 结构体:`PaymentApiKey`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:支付应用 API Key 元数据。明文只在创建支付应用响应中返回一次,表内仅保存摘要、可见前缀、作用域、使用时间和撤销状态。 +- 索引:`by_payment_api_key_app_id`、`by_payment_api_key_owner_user_id`;`key_hash` 唯一用于支付 API 鉴权。 + +### `payment_order` + +- Rust 结构体:`PaymentOrder`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:客户端游戏和外部产品的统一收款订单。保存不可变商品 / 金额快照、应用归属、接入方订单号、幂等查找键、微信 Native `code_url`、平台交易号、支付状态、过期时间和到账时间。订单确认只能由验签通知或服务端查单进入 `paid`,不与 `profile_recharge_order` 共享入账路径。 +- 索引:`by_payment_order_app_id`、`by_payment_order_owner_user_id`、`by_payment_order_status`;`idempotency_lookup`、`merchant_order_lookup`、`checkout_token` 唯一用于幂等和公共收银台访问。 + +### `payment_webhook_delivery` + +- Rust 结构体:`PaymentWebhookDelivery`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。 +- 作用:支付成功后的外部回调 durable outbox。保存 callback URL、签名、固定 payload、租约、尝试次数、下次可投递时间、最近错误和 delivered / failed 终态;api-server worker 通过 claim / complete procedure 投递。 +- 索引:`by_payment_webhook_delivery_status_available`、`by_payment_webhook_delivery_owner_user_id`、`by_payment_webhook_delivery_order_id`;`delivery_id` 按订单唯一幂等。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 ### `game_distribution_game` @@ -617,6 +641,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorAssetFolder` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布账号级素材文件夹表,归属于用户账号而不是 project;首次读取素材库时自动创建系统默认“项目素材”文件夹。文件夹支持重命名、折叠和删除,系统默认文件夹不能删除。 +- 单目录读取:`GET /api/editor/assets/folders/{folderId}` 与 `GET /api/external/v1/editor/assets/folders/{folderId}` 经同一 `spacetime-client` facade / 只读 procedure 按目录主键和 owner 校验,返回现有目录 DTO;不读取素材、不创建默认目录或修复媒体。External 要求 `editor:asset` scope;未认证 401、越权 403、缺失 404。既有全库、目录创建/修改/删除 API 合同和持久表结构保持不变;先部署后端再发布依赖新接口的 AGC 客户端,不提供全库回退。 - 索引:`by_editor_asset_folder_owner_user_id`。 ### `editor_asset` diff --git a/docs/【技术方案】外部产品支付服务接入-2026-10-03.md b/docs/【技术方案】外部产品支付服务接入-2026-10-03.md new file mode 100644 index 000000000..00143f3bb --- /dev/null +++ b/docs/【技术方案】外部产品支付服务接入-2026-10-03.md @@ -0,0 +1,202 @@ +# 外部产品支付服务接入 + +更新时间:`2026-10-03` + +## 目标 + +为 Genarrative 提供一套由平台统一收款的支付服务,支持: + +- 客户端制作的游戏通过公开接口创建订单并打开 Web 收银台。 +- 外部产品通过应用身份、API Key 和签名调用同一套订单接口。 +- 平台使用已开通的微信支付 / 支付宝商户接口生成动态支付二维码,并以服务端验签、查单或支付通知确认到账。 +- 用户在自己的账户中查看支付服务,创建和配置接入应用,管理 API Key、回调地址和订单。 +- 后台查看支付应用、订单、支付通知、回调投递和异常状态。 +- 首期只负责收款、记账和结果通知;外部产品的余额、手续费、提现和自动分账不在首期范围内,人工结算依据后台账单处理。 + +## 非目标 + +- 不把外部产品订单记入现有的个人钱包充值订单,不向购买者自动发放泥点或会员权益。 +- 不允许客户端或外部产品直接声明“支付成功”改变订单状态。 +- 不保存或向前端下发商户私钥、API v3 密钥、支付宝应用私钥、支付平台 access token 等敏感凭据。 +- 不在首期实现平台余额、外部产品提现、手续费规则、自动分账、佣金和税务结算。 +- 不使用个人收款码、静态收款码或无法验签查单的人工转账作为支付服务正式渠道。 + +## 入口与边界 + +### 参与方 + +- 平台:持有并配置自己的微信支付 / 支付宝商户账户,维护支付服务、订单和通知。 +- 接入方:平台账号下创建的游戏或外部产品应用,使用平台发放的 API Key 创建订单。 +- 购买者:在公共 Web 收银台查看商品信息并完成扫码支付,可不登录平台账号。 +- 支付平台:微信支付 Native、支付宝当面付等已开通的商户 API,负责实际收款、通知和查单。 + +### 入口 + +- 账号侧:`/profile/payment`,展示支付服务状态、应用列表、API Key、回调配置和订单入口。 +- 公共收银台:`/pay/{checkoutToken}`,由服务端返回订单快照、可用支付方式和动态二维码。 +- 外部 API:`/api/external/v1/payment/orders` 及同一资源下的查单、关闭和收银台地址接口。 +- 支付平台通知:按渠道配置独立的 HTTPS 通知入口,统一进入支付订单确认流程。 +- 后台:增加支付服务概览、接入应用、支付订单、通知与回调投递页面,并受现有后台 Tab 权限控制。 + +### 职责边界 + +- `api-server`:HTTP 鉴权、公开 API、收银台 BFF、支付平台通知、回调投递编排和错误 envelope。 +- `module-*`:金额、订单状态、幂等、归属、回调重放和人工复核等领域规则。 +- `spacetime-module`:支付应用、订单、支付尝试、通知观察和投递 outbox 的持久化与原子事务。 +- `spacetime-client`:为 api-server 提供支付领域 procedure 的 typed facade。 +- `platform-*`:微信 / 支付宝的下单、二维码、验签、解密、查单和响应解析。 +- `shared-contracts` / `packages/shared`:账号页、后台页和外部 API 共用的 DTO,不保存业务真相。 +- 前端:展示后端返回的订单状态,收银台轮询或 SSE 只用于刷新,不自行判定到账。 + +## 接入应用与账户配置 + +### 应用 + +每个应用属于一个平台账号,至少包含: + +- `appId`、应用名称、产品类型、启用状态和创建 / 更新时间。 +- 允许使用的支付渠道和金额上下限。 +- 支付成功回调地址、回调签名密钥版本、回调重试策略。 +- 可选的返回地址;必须按 HTTPS、域名白名单和协议校验,不能接受任意用户提交的跳转地址。 +- 应用级订单前缀和展示品牌信息,不能覆盖平台商户主体。 + +### API Key + +- 创建时只展示一次完整 secret,数据库只保存 hash、可见前缀、scope、创建时间、最后使用时间、撤销时间和版本。 +- 首期 scope 至少区分 `payment:orders:create`、`payment:orders:read`、`payment:orders:close`。 +- Key 只属于一个应用,撤销后立即禁止创建订单、查单和关闭操作。 +- 请求使用 `Authorization: Bearer` 或约定的 API Key 头,并要求 `Idempotency-Key`。 +- 管理员页面可以查看安全元数据,不能查看完整 secret。 + +### 商户配置 + +- 商户号、应用 ID、证书序列号、通知地址和支付平台密钥只由服务端环境变量或受控密文配置提供。 +- 用户账户页面只展示已启用的支付渠道和配置状态,不允许用户上传或读取平台级商户私钥。 +- 后台只展示脱敏的商户配置和连通性状态;密钥写入日志、追踪事件和错误响应均禁止。 +- 每个渠道必须明确 `enabled / unavailable / misconfigured` 状态,未完成配置的渠道不出现在收银台可选项。 + +## 订单与金额契约 + +### 创建订单 + +接入方提交: + +- 应用 API Key、幂等键和接入方订单号。 +- 商品标题、商品明细、商品数量、币种和金额,金额使用整数分。 +- 接入方用户标识和可选的业务 metadata;metadata 有字节上限,禁止放入凭据、Data URL 或大段正文。 +- 支付成功后的业务回调要求由应用配置提供,不能在每次请求中绕过已审核的地址白名单。 + +服务端执行: + +1. 校验应用、API Key、scope、幂等键、金额范围、币种和商品字段。 +2. 以 `appId + idempotencyKey` 建立唯一幂等记录;相同请求重放返回原订单,相同幂等键但字段不一致则失败。 +3. 生成平台订单号、不可猜测的 `checkoutToken` 和过期时间,保存不可变商品与金额快照。 +4. 调用选定的支付平台创建订单,要求平台返回的商户订单号、金额和渠道与本地快照一致。 +5. 返回平台无关的订单摘要、收银台 URL、过期时间和可用渠道,不返回商户请求签名或支付平台敏感字段。 + +### 状态 + +订单状态为: + +`pending → paying → paid` + +并允许从 `pending / paying` 进入 `expired / closed`,从 `paid` 进入 `refunded`(退款能力在后续里程碑接入)。支付通知、查单和人工复核只能按允许的状态转移推进;重复通知、乱序通知和重复查单必须幂等。 + +本地 `paid` 必须同时满足: + +- 平台通知验签或主动查单成功。 +- 商户订单号、平台交易号、应用、渠道、金额和币种与本地订单一致。 +- 支付平台确认时间合法且来自平台响应,不能使用服务器当前时间补齐。 + +## Web 收银台 + +收银台使用服务端生成的 `checkoutToken`,页面展示: + +- 商品名称、商品明细、订单号、应付金额和过期时间。 +- 可用支付渠道,例如“微信支付 / 支付宝”,渠道由商户配置和订单约束共同决定。 +- 当前渠道的动态二维码。二维码由平台商户 API 以当前订单金额和平台订单号生成,不复用静态码。 +- “待支付、支付确认中、支付成功、已关闭、已过期、支付异常”等后端状态。 +- 查询状态、复制金额、返回应用等操作;返回地址只使用应用白名单中的地址。 + +收银台不展示平台密钥、用户 API Key、完整回调地址和内部错误文本。浏览器轮询或 SSE 只能读取公开订单状态;出现“支付成功”页面前仍需经过平台回调或服务端查单确认。 + +页面视觉采用截图中的双栏收银台结构作为参考:左侧是订单信息和金额提示,右侧是二维码和渠道状态;移动端改为纵向布局。截图只作为布局参考,不作为支付协议、金额或商户身份依据。 + +## 通知、查单与外部回调 + +- 平台支付通知入口按渠道独立配置,先验签 / 解密,再解析订单和金额,再进入统一的订单确认事务。 +- 通知成功后快速返回平台要求的成功响应;外部产品回调通过 outbox 异步投递,不能让第三方回调慢速阻塞支付平台通知。 +- 外部回调 payload 至少包含平台订单号、接入方订单号、应用 ID、支付状态、金额、币种、平台交易号和确认时间。 +- 首期回调签名使用 `HMAC-SHA256(sha256(rawApiKey), rawBody)`,HTTP 头为 `X-Genarrative-Signature: sha256=`、`X-Genarrative-Event` 和 `X-Genarrative-Delivery-Id`;接入方验签后按订单号和 delivery id 幂等消费。独立可轮换 callback secret 作为后续安全增强项,不在本里程碑伪称已实现。 +- 投递按有限次数退避重试,记录 HTTP 状态、响应摘要、下一次重试时间和最终失败原因;不得记录完整 secret、签名原文或敏感请求体。 +- 查询订单只返回应用归属范围内的订单;公共收银台只返回公开展示字段和状态。 +- 支付确认与唯一回调 outbox 在同一数据库事务提交,回调 payload 从已确认订单构建;失败不会留下 paid 但缺 outbox 的半完成状态。每个投递使用唯一租约代次,过期 worker 不能覆盖新 worker 结果。 +- 外部下单使用不超过 32 字符的平台订单号和冻结的过期时间,独立通知 URL 为 `/api/payment/wechat/notify`;下单 claim 在库内完成,结果不确定时先查单,同一商户单号不因重试更换。 +- 应用、订单、回调 procedure 都要求已注册后端运行服务身份,匿名 SpacetimeDB 调用不得创建或确认订单。回调地址要求 HTTPS、禁止凭据和内网地址,发送时再次检查解析地址且禁止跳转;仅隔离测试环境可以允许 loopback fixture。 +- 支付平台通知、主动查单和后台人工重试全部复用同一条幂等确认路径,不各自实现入账。 + +## 后台数据页 + +后台新增支付服务入口,至少包含: + +- 总览:订单数、已支付金额、待确认订单、通知失败、外部回调失败和按渠道统计。 +- 接入应用:应用状态、所属账号、渠道、回调地址脱敏、Key 状态和最近使用时间。 +- 支付订单:应用、商品、接入方订单号、平台订单号、金额、渠道、状态、创建 / 支付时间和平台交易号。 +- 通知与投递:验签结果、通知类型、订单关联、重复次数、投递状态和下次重试时间。 +- 详情与人工处理:展示本地快照和平台事实的对照;人工操作必须记录管理员、原因、时间和期望状态,不能直接改订单金额或伪造支付成功。 + +分页列表必须使用后端分页和排序参数;金额使用整数分并在展示层格式化,时间统一转成人可读时间,不能把 SATS enum、Option 或微秒 Timestamp 原样展示。 + +## 安全、幂等与失败关闭 + +- 所有金额和商品快照以服务端持久化值为准,客户端传回的金额只作为展示输入,不能作为支付成功依据。 +- 订单创建、支付平台下单、通知确认和外部回调投递分别设置幂等键,重放不会重复创建订单、记账或发放商品。 +- 支付平台超时、网络断开、响应解析失败或结果不确定时,订单保持待确认并进入查单 / 人工复核,不自动标记失败或再次创建新订单。 +- 未知通知、金额不一致、订单归属不一致、签名失败和平台交易号冲突必须拒绝确认,并保留脱敏诊断事实。 +- 日志只记录稳定 HMAC 引用、状态、金额、渠道、阶段和错误分类,不记录二维码原文、密钥、签名、完整订单请求或敏感 metadata。 +- 所有用户、应用、订单、回调和后台查询都检查归属和权限;外部 API 不允许通过猜测订单号访问其它应用数据。 + +## 契约与迁移 + +### API / DTO / OpenAPI + +- 新增支付服务公开 API 时同步更新 `docs/openapi/genarrative-external-v1.openapi.json`、`shared-contracts`、契约测试和公开发现文档。 +- 内部账号接口放在 `/api/profile/payment/*`,后台接口放在 `/admin/api/payment/*`,支付平台通知不走用户鉴权。 +- 公开 DTO 不暴露 provider 原始响应、密钥、完整二维码 payload 或内部数据库 ID。 + +### SpacetimeDB + +计划增加独立的支付领域表,具体表结构在里程碑实施前冻结: + +- 支付接入应用和 API Key 元数据。 +- 平台支付订单和不可变商品快照。 +- 渠道支付尝试与平台交易号。 +- 支付通知观察记录。 +- `payment_webhook_delivery` 外部回调投递 outbox 与投递结果。 + +新增字段必须追加到已有 Rust 表结构体末尾并设置明确默认值;不得重排、改名或删除现有充值表字段。修改 schema 时同步 migration、表目录、生成绑定并运行 `npm run check:spacetime-schema`。 + +### 兼容策略 + +现有个人钱包充值、微信虚拟支付和充值退款链路保持原契约。支付服务用独立订单前缀、表和 API namespace 标识,不把历史充值订单迁移成外部支付订单。 + +## 首期验收标准 + +| 条款 | 验收方式 | 证据 | +| --- | --- | --- | +| 账号可创建支付应用并管理 API Key | 浏览器 smoke、接口测试 | 个人中心页面、Key 只展示一次、撤销立即生效 | +| 客户端游戏和外部产品可创建订单 | API 契约测试 | 同一幂等键返回原订单,字段冲突被拒绝 | +| 收银台展示不可篡改的商品和金额 | 浏览器 smoke | `checkoutToken` 只能读取公开字段,金额来自服务端快照 | +| 商户账户生成动态二维码 | provider mock + 真实沙箱 smoke | 二维码订单号和金额与本地订单一致 | +| 支付结果以服务端事实确认 | 通知 / 查单测试 | 客户端伪造成功不会改变订单,通知重放不重复确认 | +| 外部产品收到成功通知 | outbox 测试与回调 smoke | 签名、重试、最终失败和幂等投递可观测 | +| 后台可查看运营数据 | 后台页面测试 | 应用、订单、通知和回调列表分页、筛选、详情可用 | +| 权限与敏感数据安全 | 负向测试、日志检查 | 跨应用访问、失效 Key、错误金额、错误签名均失败且无密钥泄漏 | +| 首期不产生自动分账 | 领域测试与文档核对 | 没有余额、提现或自动分账写路径 | + +## 未决问题与决策 + +- 首期接入渠道:优先复用现有微信 Native 动态二维码链路;支付宝作为同一收银台的第二个 provider,只有商户 API 配置和验签协议完成后才显示。 +- 首期结算:平台收款、记账、通知,外部产品人工结算;手续费、余额和提现另立规范。 +- 商户主体:使用平台自己的商户号;接入应用不能选择或替换商户主体。 +- 退款:首期只展示支付事实和人工处理入口,正式退款与外部产品权益回收另立里程碑。 diff --git a/packages/shared/src/contracts/payment.ts b/packages/shared/src/contracts/payment.ts new file mode 100644 index 000000000..4683dd077 --- /dev/null +++ b/packages/shared/src/contracts/payment.ts @@ -0,0 +1,55 @@ +export type PaymentApp = { + appId: string; + name: string; + callbackUrl: string | null; + enabled: boolean; + createdAt: string; + updatedAt: string; +}; + +export type PaymentApiKey = { + keyId: string; + appId: string; + name: string; + keyPrefix: string; + scopes: string[]; + createdAt: string; + lastUsedAt: string | null; + revokedAt: string | null; + updatedAt: string; +}; + +export type PaymentAppCreateResponse = { + app: PaymentApp; + apiKey: string; + key: PaymentApiKey; +}; + +export type PaymentAppListResponse = { + apps: PaymentApp[]; + keys: PaymentApiKey[]; +}; + +export type PaymentOrder = { + orderId: string; + appId: string; + merchantOrderId: string; + title: string; + items: unknown[]; + amountCents: number; + currency: string; + provider: string; + providerTradeNo: string | null; + checkoutToken: string; + status: 'pending' | 'paying' | 'paid' | 'expired' | 'closed' | 'refunded'; + checkoutUrl: string; + providerQrCode: string | null; + createdAt: string; + expiresAt: string; + paidAt: string | null; + updatedAt: string; +}; + +export type PaymentOrderCreateResponse = { + order: PaymentOrder; +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9505d6b0f..56504ea57 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export * from './contracts/editorScene'; export * from './contracts/externalGeneration'; export * from './contracts/gameDistribution'; export type * from './contracts/hyper3d'; +export * from './contracts/payment'; export * from './contracts/runtime'; export * from './http'; export * from './llm/narrativeLanguage'; diff --git a/scripts/check-generated-bindings.mjs b/scripts/check-generated-bindings.mjs index f2a108c09..b30a70a2f 100644 --- a/scripts/check-generated-bindings.mjs +++ b/scripts/check-generated-bindings.mjs @@ -1,10 +1,18 @@ #!/usr/bin/env node /** - * 校验 ts-rs 生成的共享契约绑定与 Rust 声明一致。 + * 校验 ts-rs 生成的绑定与 Rust 声明一致。 * * 做法是「重新生成一遍并与工作区现有内容逐字节比较」:只比 git diff 会漏掉「Rust 改了但 * 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接—— * 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。 + * + * 覆盖两套 crate、四个目录: + * - `shared-contracts` → `packages/shared/src/contracts/generated`; + * - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types`。 + * + * 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录 + * 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节 + * 比较原始输出即可,不需要(也不允许)再跑格式化。 */ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; @@ -15,67 +23,125 @@ const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), '..', ); -const generatedDir = path.join( - repoRoot, - 'packages/shared/src/contracts/generated', -); -function snapshot() { - if (!fs.existsSync(generatedDir)) return new Map(); +const SHARED_CONTRACTS_DIR = 'packages/shared/src/contracts/generated'; +const AGC_CHAT_DIR = + 'apps/ai-game-creator-shell/src/view/project-development/chat/generated'; +const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated'; +const AGC_UI_EDITOR_DIR = + 'apps/ai-game-creator-shell/src/features/ui-editor/types'; + +/** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */ +const targets = [ + { + label: 'shared-contracts', + dirs: [SHARED_CONTRACTS_DIR], + cargoArgs: [ + 'test', + '--locked', + '-p', + 'shared-contracts', + '--features', + 'ts-bindings', + '--manifest-path', + 'server-rs/Cargo.toml', + 'export_bindings', + ], + hint: 'cargo test --locked -p shared-contracts --features ts-bindings --manifest-path server-rs/Cargo.toml export_bindings', + }, + { + label: 'ai-game-creator-shell', + dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR], + cargoArgs: [ + 'test', + '--locked', + '--manifest-path', + 'apps/ai-game-creator-shell/src-tauri/Cargo.toml', + 'export_bindings', + ], + hint: 'cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings', + }, +]; + +function snapshot(dir) { + const absolute = path.join(repoRoot, dir); + if (!fs.existsSync(absolute)) { + // 目录不存在时旧实现返回空 Map:若前后快照都为空,diff 什么都不报,这个 target 会以 + // "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。 + throw new Error(`生成绑定目录不存在,无法校验:${dir}`); + } + // 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录); + // 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。 + const entries = fs.readdirSync(absolute); return new Map( - fs - .readdirSync(generatedDir, { withFileTypes: true }) - .filter((entry) => entry.isFile()) - .map((entry) => [ - entry.name, - fs.readFileSync(path.join(generatedDir, entry.name)), - ]), + entries.flatMap((entry) => { + const absoluteFile = path.join(absolute, entry); + if (!fs.lstatSync(absoluteFile).isFile()) return []; + const name = path.posix.join(dir, entry.split(path.sep).join('/')); + return [[name, fs.readFileSync(absoluteFile)]]; + }), ); } -const before = snapshot(); -const result = spawnSync( - 'cargo', - [ - 'test', - '--locked', - '-p', - 'shared-contracts', - '--features', - 'ts-bindings', - '--manifest-path', - 'server-rs/Cargo.toml', - 'export_bindings', - ], - { cwd: repoRoot, encoding: 'utf8' }, -); -if (result.status !== 0) { - console.error('生成绑定校验无法运行:export_bindings 未通过'); - if (result.stdout) console.error(result.stdout.trim()); - if (result.stderr) console.error(result.stderr.trim()); - process.exit(result.status ?? 1); -} -const after = snapshot(); - -const problems = []; -for (const [name, content] of after) { - const previous = before.get(name); - if (!previous) problems.push(`新增 ${name}`); - else if (!previous.equals(content)) problems.push(`内容变化 ${name}`); -} -for (const name of before.keys()) { - if (!after.has(name)) problems.push(`删除 ${name}`); +function run(command, args) { + // Windows 上 `.cmd` / `.bat` 不带 shell 无法执行(Node 修 CVE-2024-27980 后直接 EINVAL); + // 仓库既有脚本统一用 `shell: process.platform === 'win32'`。 + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (result.error) { + console.error(`无法启动 ${command}:${result.error.message}`); + } + return result; } -if (problems.length > 0) { - console.error('生成绑定与 Rust 声明不一致:'); - for (const problem of problems) console.error(` - ${problem}`); - console.error( - '请运行 `cargo test -p shared-contracts --features ts-bindings export_bindings`,并把重新生成的结果与 Rust 改动一并提交。', +function diff(before, after) { + const problems = []; + for (const [name, content] of after) { + const previous = before.get(name); + if (!previous) problems.push(`新增 ${name}`); + else if (!previous.equals(content)) problems.push(`内容变化 ${name}`); + } + for (const name of before.keys()) { + if (!after.has(name)) problems.push(`删除 ${name}`); + } + return problems; +} + +let totalFiles = 0; +let failed = false; +for (const target of targets) { + const before = new Map( + target.dirs.flatMap((dir) => [...snapshot(dir).entries()]), ); - process.exit(1); + const result = run('cargo', target.cargoArgs); + if (result.status !== 0) { + failed = true; + console.error( + `生成绑定校验无法运行:${target.label} 的 export_bindings 未通过`, + ); + if (result.stdout) console.error(result.stdout.trim()); + if (result.stderr) console.error(result.stderr.trim()); + continue; + } + const after = new Map( + target.dirs.flatMap((dir) => [...snapshot(dir).entries()]), + ); + const problems = diff(before, after); + if (problems.length > 0) { + failed = true; + console.error(`生成绑定与 Rust 声明不一致(${target.label}):`); + for (const problem of problems) console.error(` - ${problem}`); + console.error( + `请运行 \`${target.hint}\`,并把重新生成的结果与 Rust 改动一并提交。`, + ); + } else { + totalFiles += after.size; + console.log(`生成绑定校验通过:${target.label}(${after.size} 个文件)。`); + } } -console.log( - `生成绑定校验通过:${after.size} 个文件与 Rust 声明一致(${path.relative(repoRoot, generatedDir)})。`, -); +if (failed) process.exit(1); +console.log(`生成绑定校验通过:合计 ${totalFiles} 个文件与 Rust 声明一致。`); diff --git a/scripts/spacetime-editor-idempotency-smoke.mjs b/scripts/spacetime-editor-idempotency-smoke.mjs index 3b79c0836..0e6d9b94c 100644 --- a/scripts/spacetime-editor-idempotency-smoke.mjs +++ b/scripts/spacetime-editor-idempotency-smoke.mjs @@ -384,6 +384,28 @@ function assertDeletedConflict(result, recordLabel) { } async function runSmoke(configPath, serverUrl) { + const readFolder = (folderId, ownerUserId = 'receipt-owner') => + callProcedure(configPath, serverUrl, 'get_editor_asset_folder_and_return', { + folder_id: folderId, + owner_user_id: ownerUserId, + }); + const missingBeforeCreate = await readFolder('missing-folder', 'empty-owner'); + assert( + missingBeforeCreate.ok === false && + missingBeforeCreate.errorMessage === '素材文件夹不存在', + 'Missing folder read did not return the missing-folder error.', + ); + assert( + ( + await sqlRows( + configPath, + serverUrl, + 'SELECT folder_id FROM editor_asset_folder', + 'folders after missing read', + ) + ).length === 0, + 'Missing folder read created a default folder.', + ); const projectA = projectInput('project-a', 'First'); assertOk( await callProcedure( @@ -471,6 +493,73 @@ async function runSmoke(configPath, serverUrl) { 'asset folder drift replay', ); + const folderRowsBeforeRead = await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_asset_folder', + 'folders before read', + ); + const receiptsBeforeRead = await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_idempotent_create_receipt', + 'receipts before read', + ); + assert( + ( + await sqlRows( + configPath, + serverUrl, + 'SELECT asset_id FROM editor_asset', + 'assets before folder read', + ) + ).length === 0, + 'Folder read fixture must have no assets.', + ); + const emptyFolder = await readFolder('folder-a'); + assertOk(emptyFolder, 'empty asset folder read'); + assert( + emptyFolder.record[0] === 'folder-a' && + emptyFolder.record[1] === 'Folder A', + 'Folder read returned a different folder.', + ); + const foreignFolder = await readFolder('folder-a', 'other-owner'); + assert( + foreignFolder.ok === false && + foreignFolder.record === null && + foreignFolder.errorMessage === '无权访问该素材文件夹', + 'Foreign-owner folder read did not reject access.', + ); + const missingFolder = await readFolder('missing-folder'); + assert( + missingFolder.ok === false && + missingFolder.record === null && + missingFolder.errorMessage === '素材文件夹不存在', + 'Missing folder read did not fail closed.', + ); + assert( + JSON.stringify( + await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_asset_folder', + 'folders after read', + ), + ) === JSON.stringify(folderRowsBeforeRead), + 'Folder reads changed folder rows or timestamps.', + ); + assert( + JSON.stringify( + await sqlRows( + configPath, + serverUrl, + 'SELECT * FROM editor_idempotent_create_receipt', + 'receipts after read', + ), + ) === JSON.stringify(receiptsBeforeRead), + 'Folder reads changed durable receipts.', + ); + const sameRaceInput = projectInput('project-same-race', 'Same Race'); const sameRace = await Promise.all([ callProcedure( @@ -641,6 +730,12 @@ async function runSmoke(configPath, serverUrl) { }, ); assertOk(deleteFolderResult, 'asset folder delete'); + const deletedFolder = await readFolder('folder-a'); + assert( + deletedFolder.ok === false && + deletedFolder.errorMessage === '素材文件夹不存在', + 'Deleted folder read did not return the missing-folder error.', + ); assertDeletedConflict( await callProcedure( configPath, @@ -757,7 +852,7 @@ export async function main() { await runSmoke(configPath, serverUrl); console.log( - '[editor-idempotency-smoke] Passed exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', + '[editor-idempotency-smoke] Passed folder reads (empty, missing, foreign owner, no writes), exact replay, body conflicts, deletion fail-close, concurrent calls, and orphan checks.', ); } catch (error) { const standaloneOutput = sanitizeDiagnostic( diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 22dae19e1..bfaba8290 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2201,6 +2201,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission path if path.starts_with("/admin/api/agc-templates/") => AnyTab(&["agc-templates"]), "/admin/api/dashboard" => AnyTab(&["dashboard"]), "/admin/api/overview" => AnyTab(&["overview"]), + "/admin/api/payment/orders" => AnyTab(&["payment-orders"]), "/admin/api/external-api-keys" => AnyTab(&["tables"]), "/admin/api/debug/http" => AnyTab(&["debug"]), "/admin/api/tracking/events" => AnyTab(&["tracking"]), @@ -2857,6 +2858,15 @@ async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result Result, AppError> { + fetch_admin_dashboard_rows(state, sql) + .await + .map_err(|message| AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message)) +} + /// 用户详情累计充值的单次读取上限:单个用户的历史订单远少于该值。 const ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT: u32 = 500; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 659686d1f..390031ef9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -23,6 +23,7 @@ use crate::{ external_api_auth::ExternalApiPrincipal, http_error::AppError, modules, + payment::handle_payment_wechat_notify, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::{AppState, BackpressureState}, @@ -71,6 +72,10 @@ pub fn build_router(state: AppState) -> Router { get(handle_wechat_virtual_payment_message_push_verify) .post(handle_wechat_virtual_payment_notify), ) + .route( + "/api/payment/wechat/notify", + post(handle_payment_wechat_notify), + ) // HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。 .layer(middleware::from_fn_with_state( BackpressureState::from_ref(&state), diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d67344797..db2e78e56 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -48,7 +48,7 @@ use spacetime_client::editor_project::{ }; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, @@ -2386,6 +2386,29 @@ pub async fn get_editor_asset_library( )) } +pub async fn get_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let folder = state + .spacetime_client() + .get_editor_asset_folder(EditorAssetFolderGetRecordInput { + folder_id, + owner_user_id: current_owner_user_id(&authenticated), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + pub async fn create_editor_asset_folder( State(state): State, headers: HeaderMap, diff --git a/server-rs/crates/api-server/src/external_api_auth.rs b/server-rs/crates/api-server/src/external_api_auth.rs index 307203bb1..54f349b53 100644 --- a/server-rs/crates/api-server/src/external_api_auth.rs +++ b/server-rs/crates/api-server/src/external_api_auth.rs @@ -8,7 +8,9 @@ use axum::{ response::Response, }; use serde_json::json; -use spacetime_client::ExternalApiKeyAuthenticateRecordInput; +use spacetime_client::{ + ExternalApiKeyAuthenticateRecordInput, PaymentApiKeyAuthenticateRecordInput, +}; use tracing::warn; use crate::{ @@ -24,6 +26,9 @@ pub struct ExternalApiPrincipal { owner_user_id: String, key_id: String, scopes: Vec, + payment_app_id: Option, + callback_url: Option, + key_hash: String, } impl ExternalApiPrincipal { @@ -33,6 +38,9 @@ impl ExternalApiPrincipal { owner_user_id: owner_user_id.to_string(), key_id: "external-api-key-test".to_string(), scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(), + payment_app_id: None, + callback_url: None, + key_hash: String::new(), } } @@ -44,9 +52,21 @@ impl ExternalApiPrincipal { self.key_id.as_str() } + pub fn payment_app_id(&self) -> Option<&str> { + self.payment_app_id.as_deref() + } + pub fn has_scope(&self, scope: &str) -> bool { self.scopes.iter().any(|item| item == scope) } + + pub fn payment_callback_url(&self) -> Option<&str> { + self.callback_url.as_deref() + } + + pub fn payment_key_hash(&self) -> &str { + self.key_hash.as_str() + } } pub async fn require_external_api_key( @@ -60,10 +80,11 @@ pub async fn require_external_api_key( .map(|context| context.request_id().to_string()) .unwrap_or_else(|| "unknown".to_string()); let raw_key = extract_external_api_bearer(request.headers())?; + let key_hash = hash_external_api_key(raw_key.as_str()); let key = state .authenticator() .authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput { - key_hash: hash_external_api_key(raw_key.as_str()), + key_hash: key_hash.clone(), used_at_micros: current_utc_micros(), }) .await @@ -79,6 +100,55 @@ pub async fn require_external_api_key( owner_user_id: key.owner_user_id, key_id: key.key_id, scopes: key.scopes, + payment_app_id: None, + callback_url: None, + key_hash: String::new(), + }; + request.extensions_mut().insert(principal.clone()); + + let mut response = next.run(request).await; + response.extensions_mut().insert(principal); + Ok(response) +} + +pub async fn require_payment_api_key( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let request_id = request + .extensions() + .get::() + .map(|context| context.request_id().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let raw_key = extract_external_api_bearer(request.headers())?; + let key_hash = hash_external_api_key(raw_key.as_str()); + let (app, key) = state + .authenticator() + .authenticate_payment_api_key(PaymentApiKeyAuthenticateRecordInput { + key_hash: key_hash.clone(), + used_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + warn!( + %request_id, + error = %error, + "支付 API Key 校验失败" + ); + AppError::from_status(StatusCode::UNAUTHORIZED) + .with_message("支付 API Key 不存在或已失效") + })?; + let scopes = serde_json::from_str::>(key.scopes_json.as_str()).map_err(|_| { + AppError::from_status(StatusCode::UNAUTHORIZED).with_message("支付 API Key 权限配置无效") + })?; + let principal = ExternalApiPrincipal { + owner_user_id: app.owner_user_id, + key_id: key.key_id, + scopes, + payment_app_id: Some(app.app_id), + callback_url: app.callback_url, + key_hash, }; request.extensions_mut().insert(principal.clone()); diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 07bd86ad6..123878eb0 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -17,11 +17,11 @@ use shared_contracts::external_generation::{ use shared_kernel::build_prefixed_uuid_id; use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, - EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, ExternalGenerationJobGetRecordInput, - ExternalGenerationJobRecord, SpacetimeClientError, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, + EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, + EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, }; use crate::editor_project_icon::{ @@ -468,6 +468,30 @@ pub async fn get_external_editor_asset_library( )) } +pub async fn get_external_editor_asset_folder( + State(state): State, + Path(folder_id): Path, + Extension(request_context): Extension, + Extension(principal): Extension, +) -> Result, AppError> { + require_scope(&principal, SCOPE_EDITOR_ASSET)?; + let folder = state + .spacetime_client() + .get_editor_asset_folder(EditorAssetFolderGetRecordInput { + folder_id, + owner_user_id: principal.owner_user_id().to_string(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + ExternalEditorAssetFolderResponse { + folder: editor_asset_folder_payload_from_record(folder), + }, + )) +} + pub async fn create_external_editor_asset_folder( State(state): State, headers: HeaderMap, @@ -1194,7 +1218,11 @@ fn serialize_external_editor_image_sequence_frames( mod tests { use super::*; use crate::editor_project::PROJECT_COVER_SNAPSHOT_ASSET_KIND; - use axum::{Router, body::Body, routing::post}; + use axum::{ + Router, + body::Body, + routing::{get, post}, + }; use spacetime_client::{ EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectRecord, EditorProjectResourceRecord, @@ -2317,6 +2345,72 @@ mod tests { ); } + #[tokio::test] + async fn external_editor_asset_folder_read_requires_asset_scope_before_store_access() { + let state = AppState::new(crate::config::AppConfig::default()).expect("state should build"); + let app = Router::new() + .route( + "/api/external/v1/editor/assets/folders/{folder_id}", + get(get_external_editor_asset_folder), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test("user-1", &[]))) + .with_state(state); + let response = app + .oneshot( + axum::http::Request::builder() + .uri("/api/external/v1/editor/assets/folders/folder-1") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should complete"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn external_editor_asset_folder_read_maps_missing_and_foreign_owner_errors() { + for (message, status) in [ + ("素材文件夹不存在", StatusCode::NOT_FOUND), + ("无权访问该素材文件夹", StatusCode::FORBIDDEN), + ] { + assert_eq!( + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())) + .status_code(), + status + ); + } + } + + #[test] + fn external_editor_asset_folder_read_openapi_matches_route_contract() { + let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); + let operation = + &parsed["paths"]["/api/external/v1/editor/assets/folders/{folderId}"]["get"]; + assert_eq!(operation["operationId"], "getEditorAssetFolder"); + assert_eq!( + operation["parameters"][0]["$ref"], + "#/components/parameters/FolderId" + ); + assert_eq!(operation["security"], json!([{ "ExternalApiKey": [] }])); + assert!( + operation["description"] + .as_str() + .unwrap() + .contains("editor:asset") + ); + assert_eq!( + operation["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/ExternalEditorAssetFolderResponse" + ); + for status in ["200", "400", "401", "403", "404"] { + assert!( + operation["responses"].get(status).is_some(), + "missing {status}" + ); + } + } + #[test] fn exported_openapi_json_contains_external_editor_routes_and_security() { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); diff --git a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs index 9d66978b6..02f47b42f 100644 --- a/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs +++ b/server-rs/crates/api-server/src/external_mcp/semantic/tests.rs @@ -30,7 +30,7 @@ fn prepare( #[test] fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { assert_eq!(TOOLS.len(), 15); - assert_eq!(MCP_OPERATIONS.len(), 29); + assert_eq!(MCP_OPERATIONS.len(), 30); let legacy = MCP_OPERATIONS .iter() .map(mcp_operation_tool) @@ -52,7 +52,7 @@ fn semantic_catalog_adds_fifteen_tools_without_replacing_legacy_tools() { .is_some_and(|text| !text.is_empty()) ); } - assert_eq!(names.len(), 44); + assert_eq!(names.len(), 45); } #[test] diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 8515721f5..477b7df9c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -63,6 +63,9 @@ mod modules; mod openai_image_generation; mod password_entry; mod password_management; +mod payment; +mod payment_admin; +mod payment_webhook; mod phone_auth; mod platform_errors; mod process_metrics; @@ -694,6 +697,8 @@ fn spawn_common_app_state_background_workers(state: &AppState) { fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) { spawn_common_app_state_background_workers(state); + crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone()) + .spawn_worker(); crate::error_reports::spawn_cleanup_worker(state.clone()); if should_start_profile_recharge_expiration_listener(process_role) { spawn_profile_recharge_expiration_listener(state.clone()); diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index e8eefc21c..7b02bfc46 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -25,6 +25,7 @@ use crate::{ admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review, admin_update_wallet_restriction, }, + payment_admin::admin_list_payment_orders, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, admin_list_profile_invite_codes, @@ -84,6 +85,7 @@ pub fn router(state: AppState) -> Router { ), ("/admin/api/me", get(admin_me)), ("/admin/api/overview", get(admin_overview)), + ("/admin/api/payment/orders", get(admin_list_payment_orders)), ("/admin/api/dashboard", get(admin_dashboard)), ( "/admin/api/debug/http", diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 8ff87a7ee..176864efd 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -22,10 +22,10 @@ use crate::{ create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, delete_editor_project, edit_editor_image, extract_editor_ui_design_assets, generate_editor_image, generate_editor_scene, - get_editor_asset_library, get_editor_generation_pricing, get_editor_project, - list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, - remove_editor_image_background, rename_editor_project, save_editor_project_layout, - snap_editor_image_to_pixel_art, submit_editor_asset_showcase, + get_editor_asset_folder, get_editor_asset_library, get_editor_generation_pricing, + get_editor_project, list_editor_projects, list_public_editor_project_resources, + load_recent_editor_project, remove_editor_image_background, rename_editor_project, + save_editor_project_layout, snap_editor_image_to_pixel_art, submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, update_editor_project_resource_showcase, }, @@ -110,7 +110,9 @@ pub fn router(state: AppState) -> Router { ), ( "/api/editor/assets/folders/{folder_id}", - patch(update_editor_asset_folder).delete(delete_editor_asset_folder), + get(get_editor_asset_folder) + .patch(update_editor_asset_folder) + .delete(delete_editor_asset_folder), ), ("/api/editor/assets", post(create_editor_asset)), ( @@ -291,7 +293,7 @@ mod route_contract_tests { ("/api/editor/assets/folders", &["POST"]), ( "/api/editor/assets/folders/{folder_id}", - &["PATCH", "DELETE"], + &["GET", "PATCH", "DELETE"], ), ("/api/editor/assets", &["POST"]), ("/api/editor/assets/{asset_id}", &["PATCH", "DELETE"]), diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index dff6580ea..f88171505 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -7,7 +7,9 @@ use axum::{ use crate::{ editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES, - external_api_auth::{require_external_api_key, require_external_mcp_api_key}, + external_api_auth::{ + require_external_api_key, require_external_mcp_api_key, require_payment_api_key, + }, external_assets_api::{ confirm_external_asset_object, create_external_direct_upload_ticket, get_external_asset_read_url, @@ -21,10 +23,11 @@ use crate::{ generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, generate_external_editor_image, generate_external_editor_scene, generate_external_editor_sound_effect, generate_external_editor_video, - get_external_editor_asset_library, get_external_editor_generation_job, - get_external_editor_project, list_external_editor_projects, - load_recent_external_editor_project, openapi_json, remove_external_editor_image_background, - rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset, + get_external_editor_asset_folder, get_external_editor_asset_library, + get_external_editor_generation_job, get_external_editor_project, + list_external_editor_projects, load_recent_external_editor_project, openapi_json, + remove_external_editor_image_background, rename_external_editor_project, + save_external_editor_canvas, update_external_editor_asset, update_external_editor_asset_folder, }, external_mcp, @@ -32,6 +35,7 @@ use crate::{ download_external_skill_archive, get_external_agent_integration_manifest, get_external_skill_entry, }, + payment::{create_external_payment_order, get_external_payment_order}, state::AppState, }; @@ -44,7 +48,8 @@ pub fn router(state: AppState) -> Router { require_external_mcp_api_key, )); - let auth = middleware::from_fn_with_state(state, require_external_api_key); + let auth = middleware::from_fn_with_state(state.clone(), require_external_api_key); + let payment_auth = middleware::from_fn_with_state(state.clone(), require_payment_api_key); let protected_routes = [ ( "/api/external/v1/assets/direct-upload-tickets", @@ -93,7 +98,9 @@ pub fn router(state: AppState) -> Router { ), ( "/api/external/v1/editor/assets/folders/{folder_id}", - patch(update_external_editor_asset_folder).delete(delete_external_editor_asset_folder), + get(get_external_editor_asset_folder) + .patch(update_external_editor_asset_folder) + .delete(delete_external_editor_asset_folder), ), ( "/api/external/v1/editor/assets", @@ -154,6 +161,15 @@ pub fn router(state: AppState) -> Router { .fold(Router::new(), |router, (path, methods)| { router.route(path, methods.route_layer(auth.clone())) }); + let payment_router = Router::new() + .route( + "/api/external/v1/payment/orders", + post(create_external_payment_order).route_layer(payment_auth.clone()), + ) + .route( + "/api/external/v1/payment/orders/{order_id}", + get(get_external_payment_order).route_layer(payment_auth), + ); Router::new() .route("/api/external/v1/openapi.json", get(openapi_json)) @@ -170,6 +186,7 @@ pub fn router(state: AppState) -> Router { get(download_external_skill_archive), ) .merge(protected_router) + .merge(payment_router) .merge(mcp_router) } @@ -252,7 +269,7 @@ mod route_contract_tests { ("/api/external/v1/editor/assets/folders", &["POST"]), ( "/api/external/v1/editor/assets/folders/{folder_id}", - &["PATCH", "DELETE"], + &["GET", "PATCH", "DELETE"], ), ("/api/external/v1/editor/assets", &["POST"]), ( diff --git a/server-rs/crates/api-server/src/modules/profile.rs b/server-rs/crates/api-server/src/modules/profile.rs index 633424889..8afde1908 100644 --- a/server-rs/crates/api-server/src/modules/profile.rs +++ b/server-rs/crates/api-server/src/modules/profile.rs @@ -9,6 +9,10 @@ use crate::{ create_external_api_key, ensure_llm_router_api_key, list_external_api_keys, revoke_external_api_key, }, + payment::{ + create_payment_app, get_public_payment_checkout, list_payment_apps, revoke_payment_api_key, + update_payment_app, + }, profile_identity::update_profile_identity, runtime_profile::{ claim_profile_task_reward, confirm_wechat_profile_recharge_order, @@ -60,6 +64,29 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/profile/payment/apps", + get(list_payment_apps).post(create_payment_app).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/profile/payment/apps/{app_id}", + axum::routing::patch(update_payment_app).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/profile/payment/keys/{key_id}", + axum::routing::delete(revoke_payment_api_key).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/payment/checkout/{checkout_token}", + get(get_public_payment_checkout), + ) .route( "/api/profile/wallet-ledger", get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/payment.rs b/server-rs/crates/api-server/src/payment.rs new file mode 100644 index 000000000..666edb588 --- /dev/null +++ b/server-rs/crates/api-server/src/payment.rs @@ -0,0 +1,556 @@ +use axum::{ + Extension, Json, + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, +}; +use hmac::{Hmac, Mac}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::Sha256; +use shared_contracts::payment::{ + PaymentApiKeyResponse, PaymentAppCreateRequest, PaymentAppCreateResponse, + PaymentAppListResponse, PaymentAppResponse, PaymentOrderCreateRequest, + PaymentOrderCreateResponse, PaymentOrderResponse, +}; +use shared_kernel::{build_prefixed_uuid_id, offset_datetime_to_unix_micros, parse_rfc3339}; +use spacetime_client::{ + PaymentApiKeyCreateRecordInput, PaymentAppCreateRecordInput, PaymentOrderCreateRecordInput, + PaymentOrderGetRecordInput, PaymentOrderMarkPaidRecordInput, + PaymentOrderProviderAttemptRecordInput, PaymentOrderProviderCodeRecordInput, + PaymentOrderRecord, +}; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + editor_project::current_utc_micros, + external_api_auth::ExternalApiPrincipal, + external_api_keys::hash_external_api_key, + http_error::AppError, + request_context::RequestContext, + state::AppState, + wechat::pay::{build_wechat_payment_service_order_request, map_wechat_pay_error}, +}; + +const PAYMENT_APP_ID_PREFIX: &str = "payment-app-"; +const PAYMENT_KEY_ID_PREFIX: &str = "payment-key-"; +const PAYMENT_CHECKOUT_TOKEN_PREFIX: &str = "checkout-"; +const PAYMENT_RAW_KEY_PREFIX: &str = "tnr_pay_"; +const PAYMENT_CHECKOUT_EXPIRATION_SECONDS: i64 = 5 * 60; +const PAYMENT_IDEMPOTENCY_HEADER: &str = "idempotency-key"; + +pub async fn list_payment_apps( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let apps = state + .spacetime_client() + .list_payment_apps(authenticated.claims().user_id().to_string()) + .await + .map_err(map_payment_client_error)? + .into_iter() + .map(payment_app_response) + .collect(); + let keys = state + .spacetime_client() + .list_payment_api_keys(authenticated.claims().user_id().to_string()) + .await + .map_err(map_payment_client_error)? + .into_iter() + .map(payment_api_key_response) + .collect(); + + Ok(json_success_body( + Some(&request_context), + PaymentAppListResponse { apps, keys }, + )) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppUpdateRequest { + pub enabled: bool, +} + +pub async fn update_payment_app( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(app_id): Path, + Json(payload): Json, +) -> Result, AppError> { + let app = state + .spacetime_client() + .update_payment_app(spacetime_client::PaymentAppUpdateRecordInput { + app_id, + owner_user_id: authenticated.claims().user_id().to_string(), + enabled: payload.enabled, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_payment_client_error)?; + Ok(json_success_body( + Some(&request_context), + PaymentAppResponse { + app_id: app.app_id, + name: app.name, + callback_url: app.callback_url, + enabled: app.enabled, + created_at: module_runtime::format_utc_micros(app.created_at_micros), + updated_at: module_runtime::format_utc_micros(app.updated_at_micros), + }, + )) +} + +pub async fn revoke_payment_api_key( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(key_id): Path, +) -> Result, AppError> { + let key = state + .spacetime_client() + .revoke_payment_api_key(spacetime_client::PaymentApiKeyRevokeRecordInput { + key_id, + owner_user_id: authenticated.claims().user_id().to_string(), + revoked_at_micros: current_utc_micros(), + }) + .await + .map_err(map_payment_client_error)?; + Ok(json_success_body( + Some(&request_context), + payment_api_key_response(key), + )) +} + +pub async fn create_payment_app( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let user_id = authenticated.claims().user_id().to_string(); + let now_micros = current_utc_micros(); + let app = state + .spacetime_client() + .create_payment_app(PaymentAppCreateRecordInput { + app_id: build_prefixed_uuid_id(PAYMENT_APP_ID_PREFIX), + owner_user_id: user_id.clone(), + name: payload.name, + callback_url: payload.callback_url, + now_micros, + }) + .await + .map_err(map_payment_client_error)?; + + let raw_key = format!( + "{PAYMENT_RAW_KEY_PREFIX}{}.{}", + shared_kernel::new_uuid_simple_string(), + shared_kernel::new_uuid_simple_string() + ); + let key = state + .spacetime_client() + .create_payment_api_key(PaymentApiKeyCreateRecordInput { + key_id: build_prefixed_uuid_id(PAYMENT_KEY_ID_PREFIX), + app_id: app.app_id.clone(), + owner_user_id: user_id, + name: "支付服务默认 Key".to_string(), + key_prefix: raw_key.chars().take(18).collect(), + key_hash: hash_external_api_key(raw_key.as_str()), + scopes: vec![ + module_runtime::PAYMENT_SCOPE_ORDERS_CREATE.to_string(), + module_runtime::PAYMENT_SCOPE_ORDERS_READ.to_string(), + module_runtime::PAYMENT_SCOPE_ORDERS_CLOSE.to_string(), + ], + now_micros, + }) + .await + .map_err(map_payment_client_error)?; + + Ok(json_success_body( + Some(&request_context), + PaymentAppCreateResponse { + app: payment_app_response(app), + api_key: raw_key, + key: payment_api_key_response(key), + }, + )) +} + +pub async fn create_external_payment_order( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + headers: HeaderMap, + Json(payload): Json, +) -> Result, AppError> { + require_payment_scope(&principal, module_runtime::PAYMENT_SCOPE_ORDERS_CREATE)?; + let app_id = principal.payment_app_id().ok_or_else(|| { + AppError::from_status(StatusCode::FORBIDDEN).with_message("该 Key 不是支付应用 Key") + })?; + let idempotency_key = headers + .get(PAYMENT_IDEMPOTENCY_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("缺少 Idempotency-Key") + })? + .to_string(); + if payload.provider != module_runtime::PAYMENT_PROVIDER_WECHAT_NATIVE { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("当前仅支持微信 Native 支付")); + } + let now_micros = current_utc_micros(); + let expires_at_micros = now_micros + .checked_add(PAYMENT_CHECKOUT_EXPIRATION_SECONDS * 1_000_000) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("订单有效期无效") + })?; + let items_json = serde_json::to_string(&payload.items).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("商品明细格式无效") + })?; + let order = state + .spacetime_client() + .create_payment_order(PaymentOrderCreateRecordInput { + order_id: shared_kernel::new_uuid_simple_string(), + app_id: app_id.to_string(), + owner_user_id: principal.owner_user_id().to_string(), + merchant_order_id: payload.merchant_order_id, + idempotency_lookup: format!("{app_id}:{idempotency_key}"), + title: payload.title, + items_json, + amount_cents: payload.amount_cents, + currency: payload.currency, + provider: module_runtime::PaymentProvider::WechatNative, + checkout_token: format!( + "{PAYMENT_CHECKOUT_TOKEN_PREFIX}{}", + shared_kernel::new_uuid_simple_string() + ), + now_micros, + expires_at_micros, + callback_url: principal.payment_callback_url().map(str::to_string), + callback_signing_key_hash: Some(principal.payment_key_hash().to_string()), + }) + .await + .map_err(map_payment_client_error)?; + + if order.provider_code_url.is_some() + || matches!( + order.status, + module_runtime::PaymentOrderStatus::Paid + | module_runtime::PaymentOrderStatus::Closed + | module_runtime::PaymentOrderStatus::Expired + ) + { + return Ok(json_success_body( + Some(&request_context), + PaymentOrderCreateResponse { + order: payment_order_response(order), + }, + )); + } + + let order = state + .spacetime_client() + .mark_payment_order_provider_attempted(PaymentOrderProviderAttemptRecordInput { + order_id: order.order_id, + attempted_at_micros: current_utc_micros(), + }) + .await + .map_err(map_payment_client_error)?; + let provider_payment = state + .wechat_pay_client() + .create_native_order(build_wechat_payment_service_order_request( + order.order_id.clone(), + order.title.clone(), + order.amount_cents, + resolve_payment_client_ip(&headers), + resolve_payment_notify_url()?, + )) + .await + .map_err(map_wechat_pay_error)?; + let order = state + .spacetime_client() + .set_payment_order_provider_code(PaymentOrderProviderCodeRecordInput { + order_id: order.order_id, + provider_code_url: provider_payment.code_url, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_payment_client_error)?; + + Ok(json_success_body( + Some(&request_context), + PaymentOrderCreateResponse { + order: payment_order_response(order), + }, + )) +} + +pub async fn get_external_payment_order( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + Path(order_id): Path, +) -> Result, AppError> { + require_payment_scope(&principal, module_runtime::PAYMENT_SCOPE_ORDERS_READ)?; + let order = state + .spacetime_client() + .get_payment_order(PaymentOrderGetRecordInput { + order_id, + owner_user_id: Some(principal.owner_user_id().to_string()), + }) + .await + .map_err(map_payment_client_error)?; + Ok(json_success_body( + Some(&request_context), + PaymentOrderCreateResponse { + order: payment_order_response(order), + }, + )) +} + +pub async fn get_public_payment_checkout( + State(state): State, + Extension(request_context): Extension, + Path(checkout_token): Path, +) -> Result, AppError> { + let order = state + .spacetime_client() + .get_payment_order_by_checkout_token(checkout_token) + .await + .map_err(map_payment_client_error)?; + Ok(json_success_body( + Some(&request_context), + PaymentOrderCreateResponse { + order: payment_order_response(order), + }, + )) +} + +pub async fn handle_payment_wechat_notify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Result { + let notify = state + .wechat_pay_client() + .parse_notify(&headers, &body) + .map_err(map_wechat_pay_error)?; + if notify.trade_state != "SUCCESS" { + return Ok(StatusCode::NO_CONTENT); + } + let transaction_id = notify.transaction_id.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知缺少 transaction_id") + })?; + let amount_cents = notify.amount_total_cents.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信支付成功通知缺少金额") + })?; + let paid_at_micros = notify + .success_time + .as_deref() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知缺少 success_time") + }) + .and_then(|value| { + parse_rfc3339(value) + .map(offset_datetime_to_unix_micros) + .map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知 success_time 无效") + }) + })?; + let order = state + .spacetime_client() + .get_payment_order(PaymentOrderGetRecordInput { + order_id: notify.out_trade_no, + owner_user_id: None, + }) + .await + .map_err(map_payment_client_error)?; + if order.provider != module_runtime::PaymentProvider::WechatNative + || order.amount_cents != amount_cents + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付通知与本地支付订单不一致")); + } + let paid_order = state + .spacetime_client() + .mark_payment_order_paid(PaymentOrderMarkPaidRecordInput { + order_id: order.order_id, + provider_trade_no: transaction_id, + amount_cents, + paid_at_micros, + }) + .await + .map_err(map_payment_client_error)?; + if paid_order.callback_url.is_some() { + let callback_key = paid_order + .callback_signing_key_hash + .as_deref() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("支付订单缺少外部回调签名配置") + })?; + let payload_json = serde_json::to_string(&json!({ + "event": "payment.paid", + "orderId": paid_order.order_id, + "appId": paid_order.app_id, + "merchantOrderId": paid_order.merchant_order_id, + "amountCents": paid_order.amount_cents, + "currency": paid_order.currency, + "provider": paid_order.provider.as_str(), + "providerTradeNo": paid_order.provider_trade_no, + "status": "paid", + "paidAt": paid_order.paid_at_micros.map(module_runtime::format_utc_micros), + })) + .map_err(|_| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_message("支付回调内容序列化失败") + })?; + let signature = payment_webhook_signature(callback_key, payload_json.as_bytes()); + state + .spacetime_client() + .enqueue_payment_webhook(spacetime_client::PaymentWebhookEnqueueRecordInput { + order_id: paid_order.order_id, + payload_json, + signature, + now_micros: current_utc_micros(), + }) + .await + .map_err(map_payment_client_error)?; + } + Ok(StatusCode::NO_CONTENT) +} + +fn payment_webhook_signature(key_hash: &str, payload: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(key_hash.as_bytes()) + .expect("HMAC accepts a non-empty SHA-256 key hash"); + mac.update(payload); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +} + +fn payment_app_response(record: spacetime_client::PaymentAppRecord) -> PaymentAppResponse { + PaymentAppResponse { + app_id: record.app_id, + name: record.name, + callback_url: record.callback_url, + enabled: record.enabled, + created_at: module_runtime::format_utc_micros(record.created_at_micros), + updated_at: module_runtime::format_utc_micros(record.updated_at_micros), + } +} + +fn payment_api_key_response( + record: spacetime_client::PaymentApiKeyRecord, +) -> PaymentApiKeyResponse { + PaymentApiKeyResponse { + key_id: record.key_id, + app_id: record.app_id, + name: record.name, + key_prefix: record.key_prefix, + scopes: serde_json::from_str(&record.scopes_json).unwrap_or_default(), + created_at: module_runtime::format_utc_micros(record.created_at_micros), + last_used_at: record + .last_used_at_micros + .map(module_runtime::format_utc_micros), + revoked_at: record + .revoked_at_micros + .map(module_runtime::format_utc_micros), + updated_at: module_runtime::format_utc_micros(record.updated_at_micros), + } +} + +fn payment_order_response(record: PaymentOrderRecord) -> PaymentOrderResponse { + let items = serde_json::from_str::>(&record.items_json).unwrap_or_default(); + PaymentOrderResponse { + order_id: record.order_id, + app_id: record.app_id, + merchant_order_id: record.merchant_order_id, + title: record.title, + items, + amount_cents: record.amount_cents, + currency: record.currency, + provider: record.provider.as_str().to_string(), + provider_trade_no: record.provider_trade_no, + checkout_token: record.checkout_token.clone(), + status: payment_order_status_text(record.status).to_string(), + checkout_url: format!("/pay/{}", record.checkout_token), + provider_qr_code: record.provider_code_url, + created_at: module_runtime::format_utc_micros(record.created_at_micros), + expires_at: module_runtime::format_utc_micros(record.expires_at_micros), + paid_at: record.paid_at_micros.map(module_runtime::format_utc_micros), + updated_at: module_runtime::format_utc_micros(record.updated_at_micros), + } +} + +fn payment_order_status_text(status: module_runtime::PaymentOrderStatus) -> &'static str { + match status { + module_runtime::PaymentOrderStatus::Pending => "pending", + module_runtime::PaymentOrderStatus::Paying => "paying", + module_runtime::PaymentOrderStatus::Paid => "paid", + module_runtime::PaymentOrderStatus::Expired => "expired", + module_runtime::PaymentOrderStatus::Closed => "closed", + module_runtime::PaymentOrderStatus::Refunded => "refunded", + } +} + +fn require_payment_scope(principal: &ExternalApiPrincipal, scope: &str) -> Result<(), AppError> { + if principal.payment_app_id().is_some() && principal.has_scope(scope) { + return Ok(()); + } + Err(AppError::from_status(StatusCode::FORBIDDEN) + .with_message("支付 API Key 没有当前操作权限") + .with_details(json!({ "scope": scope }))) +} + +fn resolve_payment_client_ip(headers: &HeaderMap) -> String { + headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("127.0.0.1") + .to_string() +} + +fn resolve_payment_notify_url() -> Result { + let value = std::env::var("WECHAT_PAYMENT_NOTIFY_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if let Some(value) = value { + return Ok(value); + } + if std::env::var("WECHAT_PAY_PROVIDER").ok().as_deref() == Some("mock") { + return Ok("http://127.0.0.1/api/payment/wechat/notify".to_string()); + } + Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("缺少 WECHAT_PAYMENT_NOTIFY_URL,无法创建支付服务订单")) +} + +fn map_payment_client_error(error: spacetime_client::SpacetimeClientError) -> AppError { + match error { + spacetime_client::SpacetimeClientError::Procedure(message) + if message.contains("PAYMENT_PROVIDER_PENDING") => + { + AppError::from_status(StatusCode::CONFLICT) + .with_message("支付平台下单处理中,请稍后查单") + } + spacetime_client::SpacetimeClientError::Procedure(message) + | spacetime_client::SpacetimeClientError::Runtime(message) => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("支付服务请求未通过") + .with_details(json!({ "message": message })) + } + other => AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("支付服务暂时不可用") + .with_details(json!({ "message": other.to_string() })), + } +} diff --git a/server-rs/crates/api-server/src/payment_admin.rs b/server-rs/crates/api-server/src/payment_admin.rs new file mode 100644 index 000000000..3a08bc206 --- /dev/null +++ b/server-rs/crates/api-server/src/payment_admin.rs @@ -0,0 +1,144 @@ +use crate::{ + admin::{AuthenticatedAdmin, fetch_admin_payment_rows}, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; +use axum::{Extension, Json, extract::State}; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminPaymentOrderEntry { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub title: String, + pub amount_cents: u64, + pub currency: String, + pub provider: String, + pub provider_trade_no: Option, + pub status: String, + pub created_at: String, + pub expires_at: String, + pub paid_at: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminPaymentOrderListResponse { + pub entries: Vec, + pub webhooks: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminPaymentWebhookEntry { + pub delivery_id: String, + pub order_id: String, + pub app_id: String, + pub callback_url: String, + pub status: String, + pub attempt_count: u32, + pub available_at: String, + pub last_error_message: Option, + pub updated_at: String, +} + +pub async fn admin_list_payment_orders( + State(state): State, + Extension(_admin): Extension, + Extension(_request_context): Extension, +) -> Result, AppError> { + let rows = fetch_admin_payment_rows( + &state, + "SELECT order_id, app_id, owner_user_id, merchant_order_id, title, amount_cents, currency, provider, provider_trade_no, status, created_at, expires_at, paid_at FROM payment_order ORDER BY created_at DESC LIMIT 200", + ) + .await?; + let entries = rows + .into_iter() + .filter_map(parse_payment_order_row) + .collect(); + let webhook_rows = fetch_admin_payment_rows( + &state, + "SELECT delivery_id, order_id, app_id, callback_url, status, attempt_count, available_at, last_error_message, updated_at FROM payment_webhook_delivery ORDER BY updated_at DESC LIMIT 200", + ) + .await?; + let webhooks = webhook_rows + .into_iter() + .filter_map(parse_payment_webhook_row) + .collect(); + Ok(Json(AdminPaymentOrderListResponse { entries, webhooks })) +} + +fn parse_payment_webhook_row(row: Value) -> Option { + let cells = row.as_array()?; + Some(AdminPaymentWebhookEntry { + delivery_id: cell_text(cells.get(0)?)?, + order_id: cell_text(cells.get(1)?)?, + app_id: cell_text(cells.get(2)?)?, + callback_url: cell_text(cells.get(3)?)?, + status: cell_text(cells.get(4)?)?, + attempt_count: cell_u64(cells.get(5)?)? as u32, + available_at: cell_timestamp(cells.get(6)?), + last_error_message: cell_option_text(cells.get(7)?), + updated_at: cell_timestamp(cells.get(8)?), + }) +} + +fn parse_payment_order_row(row: Value) -> Option { + let cells = row.as_array()?; + Some(AdminPaymentOrderEntry { + order_id: cell_text(cells.get(0)?)?, + app_id: cell_text(cells.get(1)?)?, + owner_user_id: cell_text(cells.get(2)?)?, + merchant_order_id: cell_text(cells.get(3)?)?, + title: cell_text(cells.get(4)?)?, + amount_cents: cell_u64(cells.get(5)?)?, + currency: cell_text(cells.get(6)?)?, + provider: cell_text(cells.get(7)?)?, + provider_trade_no: cell_option_text(cells.get(8)?), + status: cell_text(cells.get(9)?)?, + created_at: cell_timestamp(cells.get(10)?), + expires_at: cell_timestamp(cells.get(11)?), + paid_at: cell_option_timestamp(cells.get(12)?), + }) +} + +fn cell_text(value: &Value) -> Option { + match value { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Array(values) if values.len() == 2 => cell_text(values.get(1)?), + Value::Array(values) if values.len() == 1 => cell_text(values.first()?), + Value::Object(object) if object.len() == 1 => cell_text(object.values().next()?), + _ => Some(value.to_string()), + } +} + +fn cell_option_text(value: &Value) -> Option { + if matches!(value, Value::Array(values) if values.first().and_then(Value::as_u64) == Some(1)) { + return None; + } + cell_text(value).filter(|text| !text.is_empty() && text != "null") +} + +fn cell_u64(value: &Value) -> Option { + cell_text(value)?.parse().ok() +} + +fn cell_timestamp(value: &Value) -> String { + cell_text(value) + .and_then(|text| text.parse::().ok()) + .map(module_runtime::format_utc_micros) + .unwrap_or_else(|| "-".to_string()) +} + +fn cell_option_timestamp(value: &Value) -> Option { + if matches!(value, Value::Array(values) if values.first().and_then(Value::as_u64) == Some(1)) { + return None; + } + Some(cell_timestamp(value)) +} diff --git a/server-rs/crates/api-server/src/payment_webhook.rs b/server-rs/crates/api-server/src/payment_webhook.rs new file mode 100644 index 000000000..ae6ba9692 --- /dev/null +++ b/server-rs/crates/api-server/src/payment_webhook.rs @@ -0,0 +1,135 @@ +use std::sync::Arc; +use std::time::Duration; + +use reqwest::header::{CONTENT_TYPE, HeaderName, HeaderValue}; +use spacetime_client::{ + PaymentWebhookClaimRecordInput, PaymentWebhookCompleteRecordInput, + PaymentWebhookDeliveryRecord, SpacetimeClient, +}; +use tokio::time::sleep; +use tracing::{debug, warn}; + +use crate::editor_project::current_utc_micros; + +const PAYMENT_WEBHOOK_WORKER_ID: &str = "api-server-payment-webhook"; +const PAYMENT_WEBHOOK_BATCH_SIZE: u32 = 20; +const PAYMENT_WEBHOOK_INTERVAL: Duration = Duration::from_secs(5); +const PAYMENT_WEBHOOK_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone)] +pub struct PaymentWebhookWorker { + spacetime_client: SpacetimeClient, + http_client: reqwest::Client, +} + +impl PaymentWebhookWorker { + pub fn new(spacetime_client: SpacetimeClient) -> Arc { + Arc::new(Self { + spacetime_client, + http_client: reqwest::Client::builder() + .connect_timeout(Duration::from_secs(3)) + .timeout(PAYMENT_WEBHOOK_REQUEST_TIMEOUT) + .build() + .expect("payment webhook client should build"), + }) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + loop { + if let Err(error) = self.process_once().await { + warn!(error = %error, "支付外部回调 worker 处理失败,将继续重试"); + } + sleep(PAYMENT_WEBHOOK_INTERVAL).await; + } + }); + } + + async fn process_once(&self) -> Result<(), String> { + let now_micros = current_utc_micros(); + let lease_expires_at_micros = now_micros.saturating_add(60_000_000); + let deliveries = self + .spacetime_client + .claim_payment_webhooks(PaymentWebhookClaimRecordInput { + worker_id: PAYMENT_WEBHOOK_WORKER_ID.to_string(), + limit: PAYMENT_WEBHOOK_BATCH_SIZE, + now_micros, + lease_expires_at_micros, + }) + .await + .map_err(|error| error.to_string())?; + + for delivery in deliveries { + self.deliver_one(delivery).await; + } + Ok(()) + } + + async fn deliver_one(&self, delivery: PaymentWebhookDeliveryRecord) { + let result = self + .http_client + .post(&delivery.callback_url) + .header(CONTENT_TYPE, "application/json") + .header( + HeaderName::from_static("x-genarrative-signature"), + HeaderValue::from_str(delivery.signature.as_str()) + .unwrap_or_else(|_| HeaderValue::from_static("invalid")), + ) + .header( + HeaderName::from_static("x-genarrative-event"), + "payment.paid", + ) + .header( + HeaderName::from_static("x-genarrative-delivery-id"), + delivery.delivery_id.as_str(), + ) + .body(delivery.payload_json.clone()) + .send() + .await; + + let (success, error_message) = match result { + Ok(response) if response.status().is_success() => (true, None), + Ok(response) => ( + false, + Some(format!("callback HTTP {}", response.status().as_u16())), + ), + Err(error) => (false, Some(format!("callback request failed: {error}"))), + }; + let now_micros = current_utc_micros(); + let next_available_at_micros = if success { + now_micros + } else { + let delay_seconds = 2_i64 + .saturating_pow(delivery.attempt_count.min(7)) + .saturating_mul(5) + .min(300); + now_micros.saturating_add(delay_seconds.saturating_mul(1_000_000)) + }; + if let Err(error) = self + .spacetime_client + .complete_payment_webhook(PaymentWebhookCompleteRecordInput { + delivery_id: delivery.delivery_id.clone(), + worker_id: PAYMENT_WEBHOOK_WORKER_ID.to_string(), + success, + error_message, + now_micros, + next_available_at_micros, + attempt_count: delivery.attempt_count, + }) + .await + { + warn!( + delivery_id = %delivery.delivery_id, + error = %error, + "支付外部回调结果写回失败" + ); + } else { + debug!( + delivery_id = %delivery.delivery_id, + success, + attempt_count = delivery.attempt_count, + "支付外部回调处理完成" + ); + } + } +} diff --git a/server-rs/crates/api-server/src/state/external_api_auth.rs b/server-rs/crates/api-server/src/state/external_api_auth.rs index 2e7f7cb85..93b6d14ac 100644 --- a/server-rs/crates/api-server/src/state/external_api_auth.rs +++ b/server-rs/crates/api-server/src/state/external_api_auth.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use axum::extract::FromRef; use futures_util::future::BoxFuture; use spacetime_client::{ - ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClient, + ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, + PaymentApiKeyAuthenticateRecordInput, PaymentApiKeyRecord, PaymentAppRecord, SpacetimeClient, SpacetimeClientError, }; @@ -14,6 +15,17 @@ pub(crate) trait ExternalApiKeyAuthenticator: Send + Sync { &self, input: ExternalApiKeyAuthenticateRecordInput, ) -> BoxFuture<'_, Result>; + + fn authenticate_payment_api_key( + &self, + _input: PaymentApiKeyAuthenticateRecordInput, + ) -> BoxFuture<'_, Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError>> { + Box::pin(async { + Err(SpacetimeClientError::Procedure( + "支付 API Key 认证未配置".to_string(), + )) + }) + } } impl ExternalApiKeyAuthenticator for SpacetimeClient { @@ -23,6 +35,13 @@ impl ExternalApiKeyAuthenticator for SpacetimeClient { ) -> BoxFuture<'_, Result> { Box::pin(SpacetimeClient::authenticate_external_api_key(self, input)) } + + fn authenticate_payment_api_key( + &self, + input: PaymentApiKeyAuthenticateRecordInput, + ) -> BoxFuture<'_, Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError>> { + Box::pin(SpacetimeClient::authenticate_payment_api_key(self, input)) + } } #[derive(Clone)] diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 3cc839884..85ebf9583 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -1089,6 +1089,23 @@ pub fn build_wechat_web_payment_request( description: format!("陶泥儿 - {product_title}"), amount_cents, payer_client_ip, + notify_url: None, + } +} + +pub fn build_wechat_payment_service_order_request( + order_id: String, + product_title: String, + amount_cents: u64, + payer_client_ip: String, + notify_url: String, +) -> WechatWebOrderRequest { + WechatWebOrderRequest { + order_id, + description: format!("陶泥儿 - {product_title}"), + amount_cents, + payer_client_ip, + notify_url: Some(notify_url), } } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index ae6bb9fb9..fb0cb083d 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -3,6 +3,8 @@ pub mod civil_calendar; pub mod membership; mod agc_models; +mod payment; +pub use payment::*; mod application; mod commands; mod domain; diff --git a/server-rs/crates/module-runtime/src/payment.rs b/server-rs/crates/module-runtime/src/payment.rs new file mode 100644 index 000000000..2a08a934a --- /dev/null +++ b/server-rs/crates/module-runtime/src/payment.rs @@ -0,0 +1,532 @@ +use serde::{Deserialize, Serialize}; +use url::Url; + +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const PAYMENT_PROVIDER_WECHAT_NATIVE: &str = "wechat_native"; +pub const PAYMENT_SCOPE_ORDERS_CREATE: &str = "payment:orders:create"; +pub const PAYMENT_SCOPE_ORDERS_READ: &str = "payment:orders:read"; +pub const PAYMENT_SCOPE_ORDERS_CLOSE: &str = "payment:orders:close"; +pub const PAYMENT_APP_NAME_MAX_CHARS: usize = 80; +pub const PAYMENT_ORDER_TITLE_MAX_CHARS: usize = 128; +pub const PAYMENT_MERCHANT_ORDER_ID_MAX_CHARS: usize = 96; +pub const PAYMENT_IDEMPOTENCY_KEY_MAX_CHARS: usize = 128; +pub const PAYMENT_CURRENCY_MAX_CHARS: usize = 16; +pub const PAYMENT_ITEMS_MAX_BYTES: usize = 16 * 1024; +pub const PAYMENT_CALLBACK_URL_MAX_CHARS: usize = 512; +pub const PAYMENT_ORDER_MIN_AMOUNT_CENTS: u64 = 1; +pub const PAYMENT_ORDER_MAX_AMOUNT_CENTS: u64 = 9_999_999_999; +pub const PAYMENT_WEBHOOK_MAX_ATTEMPTS: u32 = 8; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaymentOrderStatus { + Pending, + Paying, + Paid, + Expired, + Closed, + Refunded, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PaymentProvider { + WechatNative, +} + +impl PaymentProvider { + pub fn as_str(self) -> &'static str { + match self { + Self::WechatNative => PAYMENT_PROVIDER_WECHAT_NATIVE, + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppSnapshot { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentApiKeySnapshot { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub scopes_json: String, + pub created_at_micros: i64, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderSnapshot { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at_micros: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at_micros: i64, + pub expires_at_micros: i64, + pub paid_at_micros: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppCreateInput { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub now_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentApiKeyCreateInput { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub key_hash: String, + pub scopes_json: String, + pub now_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppListInput { + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppUpdateInput { + pub app_id: String, + pub owner_user_id: String, + pub enabled: bool, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentApiKeyListInput { + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentApiKeyRevokeInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentApiKeyAuthenticateInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderCreateInput { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub checkout_token: String, + pub now_micros: i64, + pub expires_at_micros: i64, + pub callback_url: Option, + pub callback_signing_key_hash: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderGetInput { + pub order_id: String, + pub owner_user_id: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderCheckoutGetInput { + pub checkout_token: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderListInput { + pub app_id: Option, + pub owner_user_id: String, + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderMarkPaidInput { + pub order_id: String, + pub provider_trade_no: String, + pub amount_cents: u64, + pub paid_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderProviderCodeInput { + pub order_id: String, + pub provider_code_url: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderProviderAttemptInput { + pub order_id: String, + pub attempted_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentWebhookDeliverySnapshot { + pub delivery_id: String, + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub callback_url: String, + pub payload_json: String, + pub signature: String, + pub status: String, + pub attempt_count: u32, + pub available_at_micros: i64, + pub lease_worker_id: Option, + pub lease_expires_at_micros: Option, + pub last_error_message: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentWebhookEnqueueInput { + pub order_id: String, + pub payload_json: String, + pub signature: String, + pub now_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentWebhookClaimInput { + pub worker_id: String, + pub limit: u32, + pub now_micros: i64, + pub lease_expires_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentWebhookCompleteInput { + pub delivery_id: String, + pub worker_id: String, + pub success: bool, + pub error_message: Option, + pub now_micros: i64, + pub next_available_at_micros: i64, + pub attempt_count: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentWebhookProcedureResult { + pub ok: bool, + pub delivery: Option, + pub deliveries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentAppProcedureResult { + pub ok: bool, + pub app: Option, + pub key: Option, + pub apps: Vec, + pub keys: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaymentOrderProcedureResult { + pub ok: bool, + pub order: Option, + pub orders: Vec, + pub error_message: Option, +} + +pub fn build_payment_app_create_input( + app_id: String, + owner_user_id: String, + name: String, + callback_url: Option, + now_micros: i64, +) -> Result { + let app_id = required(app_id, "payment_app.app_id")?; + let owner_user_id = required(owner_user_id, "payment_app.owner_user_id")?; + let name = bounded(name, "payment_app.name", PAYMENT_APP_NAME_MAX_CHARS)?; + let callback_url = callback_url + .map(|value| { + let value = bounded( + value, + "payment_app.callback_url", + PAYMENT_CALLBACK_URL_MAX_CHARS, + )?; + let parsed = Url::parse(value.as_str()) + .map_err(|_| "payment_app.callback_url 格式无效".to_string())?; + let host = parsed + .host_str() + .ok_or_else(|| "payment_app.callback_url 缺少主机名".to_string())?; + let loopback_http = parsed.scheme() == "http" + && (host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback())); + if parsed.scheme() != "https" && !loopback_http { + return Err("payment_app.callback_url 必须使用 HTTPS".to_string()); + } + if parsed.username() != "" || parsed.password().is_some() || parsed.fragment().is_some() + { + return Err("payment_app.callback_url 不得包含凭据或 fragment".to_string()); + } + Ok(value) + }) + .transpose()?; + Ok(PaymentAppCreateInput { + app_id, + owner_user_id, + name, + callback_url, + now_micros, + }) +} + +pub fn build_payment_api_key_create_input( + key_id: String, + app_id: String, + owner_user_id: String, + name: String, + key_prefix: String, + key_hash: String, + scopes_json: String, + now_micros: i64, +) -> Result { + Ok(PaymentApiKeyCreateInput { + key_id: required(key_id, "payment_api_key.key_id")?, + app_id: required(app_id, "payment_api_key.app_id")?, + owner_user_id: required(owner_user_id, "payment_api_key.owner_user_id")?, + name: bounded(name, "payment_api_key.name", PAYMENT_APP_NAME_MAX_CHARS)?, + key_prefix: required(key_prefix, "payment_api_key.key_prefix")?, + key_hash: required(key_hash, "payment_api_key.key_hash")?, + scopes_json: bounded(scopes_json, "payment_api_key.scopes_json", 512)?, + now_micros, + }) +} + +pub fn build_payment_order_create_input( + order_id: String, + app_id: String, + owner_user_id: String, + merchant_order_id: String, + idempotency_lookup: String, + title: String, + items_json: String, + amount_cents: u64, + currency: String, + provider: PaymentProvider, + checkout_token: String, + now_micros: i64, + expires_at_micros: i64, + callback_url: Option, + callback_signing_key_hash: Option, +) -> Result { + if !(PAYMENT_ORDER_MIN_AMOUNT_CENTS..=PAYMENT_ORDER_MAX_AMOUNT_CENTS).contains(&amount_cents) { + return Err("payment_order.amount_cents 超出允许范围".to_string()); + } + if currency != "CNY" { + return Err("PAYMENT_INVALID_AMOUNT_OR_CURRENCY".to_string()); + } + let items: serde_json::Value = + serde_json::from_str(&items_json).map_err(|_| "PAYMENT_INVALID_ITEMS".to_string())?; + if !items.is_array() + || items + .as_array() + .is_some_and(|rows| rows.iter().any(|v| !v.is_object())) + || items_json.to_lowercase().contains("data:") + || items_json.to_lowercase().contains("blob:") + { + return Err("PAYMENT_INVALID_ITEMS".to_string()); + } + if items_json.len() > PAYMENT_ITEMS_MAX_BYTES { + return Err("payment_order.items_json 超出大小限制".to_string()); + } + if expires_at_micros <= now_micros { + return Err("payment_order.expires_at_micros 必须晚于创建时间".to_string()); + } + Ok(PaymentOrderCreateInput { + order_id: required(order_id, "payment_order.order_id")?, + app_id: required(app_id, "payment_order.app_id")?, + owner_user_id: required(owner_user_id, "payment_order.owner_user_id")?, + merchant_order_id: bounded( + merchant_order_id, + "payment_order.merchant_order_id", + PAYMENT_MERCHANT_ORDER_ID_MAX_CHARS, + )?, + idempotency_lookup: bounded( + idempotency_lookup, + "payment_order.idempotency_lookup", + PAYMENT_IDEMPOTENCY_KEY_MAX_CHARS + 128, + )?, + title: bounded(title, "payment_order.title", PAYMENT_ORDER_TITLE_MAX_CHARS)?, + items_json, + amount_cents, + currency: bounded( + currency, + "payment_order.currency", + PAYMENT_CURRENCY_MAX_CHARS, + )?, + provider, + checkout_token: required(checkout_token, "payment_order.checkout_token")?, + now_micros, + expires_at_micros, + callback_url: callback_url + .map(|value| { + bounded( + value, + "payment_order.callback_url", + PAYMENT_CALLBACK_URL_MAX_CHARS, + ) + }) + .transpose()?, + callback_signing_key_hash: callback_signing_key_hash + .map(|value| required(value, "payment_order.callback_signing_key_hash")) + .transpose()?, + }) +} + +pub fn build_payment_order_mark_paid_input( + order_id: String, + provider_trade_no: String, + amount_cents: u64, + paid_at_micros: i64, +) -> Result { + if paid_at_micros <= 0 || amount_cents == 0 { + return Err("PAYMENT_INVALID_PROVIDER_FACT".to_string()); + } + Ok(PaymentOrderMarkPaidInput { + order_id: required(order_id, "payment_order.order_id")?, + provider_trade_no: required(provider_trade_no, "payment_order.provider_trade_no")?, + amount_cents, + paid_at_micros, + }) +} + +fn required(value: String, field: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(value) +} + +fn bounded(value: String, field: &str, max_chars: usize) -> Result { + let value = required(value, field)?; + if value.chars().count() > max_chars { + return Err(format!("{field} 超出长度限制")); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payment_order_validation_rejects_zero_amount_and_expired_order() { + assert!( + build_payment_order_create_input( + "order".into(), + "app".into(), + "owner".into(), + "merchant".into(), + "lookup".into(), + "商品".into(), + "[]".into(), + 0, + "CNY".into(), + PaymentProvider::WechatNative, + "checkout".into(), + 10, + 20, + None, + None, + ) + .is_err() + ); + assert!( + build_payment_order_create_input( + "order".into(), + "app".into(), + "owner".into(), + "merchant".into(), + "lookup".into(), + "商品".into(), + "[]".into(), + 100, + "CNY".into(), + PaymentProvider::WechatNative, + "checkout".into(), + 20, + 20, + None, + None, + ) + .is_err() + ); + } +} diff --git a/server-rs/crates/platform-wechat/src/pay.rs b/server-rs/crates/platform-wechat/src/pay.rs index d3d862883..075c163ce 100644 --- a/server-rs/crates/platform-wechat/src/pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -192,6 +192,7 @@ pub struct WechatWebOrderRequest { pub description: String, pub amount_cents: u64, pub payer_client_ip: String, + pub notify_url: Option, } #[derive(Clone, Debug)] @@ -985,7 +986,7 @@ impl RealWechatPayClient { description: &request.description, out_trade_no: &request.order_id, time_expire: &expires_at_text, - notify_url: &self.notify_url, + notify_url: request.notify_url.as_deref().unwrap_or(&self.notify_url), amount: WechatJsapiAmount { total: amount_total, currency: "CNY", @@ -1040,7 +1041,7 @@ impl RealWechatPayClient { description: &request.description, out_trade_no: &request.order_id, time_expire: &expires_at_text, - notify_url: &self.notify_url, + notify_url: request.notify_url.as_deref().unwrap_or(&self.notify_url), amount: WechatJsapiAmount { total: amount_total, currency: "CNY", diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 07fbbdf11..ed92100fb 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -23,4 +23,5 @@ pub mod game_creation_app; pub mod game_distribution; pub mod hyper3d; pub mod llm; +pub mod payment; pub mod runtime; diff --git a/server-rs/crates/shared-contracts/src/payment.rs b/server-rs/crates/shared-contracts/src/payment.rs new file mode 100644 index 000000000..866a4d940 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/payment.rs @@ -0,0 +1,106 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppCreateRequest { + pub name: String, + #[serde(default)] + pub callback_url: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppResponse { + pub app_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentApiKeyResponse { + pub key_id: String, + pub app_id: String, + pub name: String, + pub key_prefix: String, + pub scopes: Vec, + pub created_at: String, + pub last_used_at: Option, + pub revoked_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppCreateResponse { + pub app: PaymentAppResponse, + pub api_key: String, + pub key: PaymentApiKeyResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAppListResponse { + pub apps: Vec, + pub keys: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderCreateRequest { + pub merchant_order_id: String, + pub title: String, + #[serde(default)] + pub items: Vec, + pub amount_cents: u64, + #[serde(default = "default_payment_currency")] + pub currency: String, + #[serde(default = "default_payment_provider")] + pub provider: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderResponse { + pub order_id: String, + pub app_id: String, + pub merchant_order_id: String, + pub title: String, + pub items: Vec, + pub amount_cents: u64, + pub currency: String, + pub provider: String, + pub provider_trade_no: Option, + pub checkout_token: String, + pub status: String, + pub checkout_url: String, + pub provider_qr_code: Option, + pub created_at: String, + pub expires_at: String, + pub paid_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderCreateResponse { + pub order: PaymentOrderResponse, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PaymentOrderListResponse { + pub orders: Vec, +} + +pub fn default_payment_currency() -> String { + "CNY".to_string() +} + +pub fn default_payment_provider() -> String { + "wechat_native".to_string() +} diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index 83526c14b..809e881df 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -35,6 +35,8 @@ pub use game_distribution::{ #[path = "active/external_generation.rs"] pub mod external_generation; pub mod llm_router_account; +#[path = "active/payment.rs"] +pub mod payment; pub use external_generation::{ ExternalGenerationJobPhaseUpdateError, ExternalGenerationQueueWakeSubscription, }; diff --git a/server-rs/crates/spacetime-client/src/active/mapper.rs b/server-rs/crates/spacetime-client/src/active/mapper.rs index 5e59eeedb..07b1a9cf8 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper.rs @@ -24,6 +24,8 @@ mod external_generation; mod game_distribution; #[path = "mapper/llm_router_account.rs"] pub(crate) mod llm_router_account; +#[path = "mapper/payment.rs"] +mod payment; #[path = "mapper/runtime.rs"] mod runtime; #[path = "mapper/runtime_profile.rs"] @@ -52,7 +54,7 @@ pub use self::editor_agent::{ pub use self::editor_project::{ AdminEditorAssetListRecordInput, AdminEditorAssetRecord, EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, - EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderGetRecordInput, EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetGroupCohortCompleteRecordInput, EditorAssetGroupSourceLookupRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, @@ -103,6 +105,15 @@ pub use self::game_distribution::{ GameDistributionVersionRecord, }; pub use self::llm_router_account::{LlmRouterAccountRecord, LlmRouterAccountUpsertRecordInput}; +pub use self::payment::{ + PaymentApiKeyAuthenticateRecordInput, PaymentApiKeyCreateRecordInput, PaymentApiKeyRecord, + PaymentApiKeyRevokeRecordInput, PaymentAppCreateRecordInput, PaymentAppRecord, + PaymentAppUpdateRecordInput, PaymentOrderCreateRecordInput, PaymentOrderGetRecordInput, + PaymentOrderListRecordInput, PaymentOrderMarkPaidRecordInput, + PaymentOrderProviderAttemptRecordInput, PaymentOrderProviderCodeRecordInput, + PaymentOrderRecord, PaymentWebhookClaimRecordInput, PaymentWebhookCompleteRecordInput, + PaymentWebhookDeliveryRecord, PaymentWebhookEnqueueRecordInput, +}; pub use self::runtime::FeatureGateConfigRecord; pub(crate) use self::admin_account::{ @@ -161,6 +172,12 @@ pub(crate) use self::game_distribution::{ map_game_distribution_public_game_result, map_game_distribution_review_list_result, map_game_distribution_version_result, map_review_moderation_operation, map_user_review, }; +pub(crate) use self::payment::{ + map_payment_api_key_list_result, map_payment_api_key_result, map_payment_app_list_result, + map_payment_app_result, map_payment_authenticate_result, map_payment_order_list_result, + map_payment_order_result, map_payment_webhook_claim_result, + map_payment_webhook_complete_result, map_payment_webhook_enqueue_result, +}; pub(crate) use self::runtime::{ map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result, map_runtime_tracking_event_batch_procedure_result, map_runtime_tracking_event_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 671d20708..5e9f4c69c 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -362,6 +362,12 @@ pub struct EditorProjectResourcePublicShowcaseListRecordInput { pub cursor: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAssetFolderGetRecordInput { + pub folder_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorAssetFolderCreateRecordInput { pub folder_id: String, @@ -711,6 +717,15 @@ impl From } } +impl From for crate::module_bindings::EditorAssetFolderGetInput { + fn from(input: EditorAssetFolderGetRecordInput) -> Self { + Self { + folder_id: input.folder_id, + owner_user_id: input.owner_user_id, + } + } +} + impl From for crate::module_bindings::EditorAssetFolderCreateInput { diff --git a/server-rs/crates/spacetime-client/src/active/mapper/payment.rs b/server-rs/crates/spacetime-client/src/active/mapper/payment.rs new file mode 100644 index 000000000..43c482d36 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/active/mapper/payment.rs @@ -0,0 +1,516 @@ +use super::*; + +pub type PaymentAppRecord = module_runtime::PaymentAppSnapshot; +pub type PaymentApiKeyRecord = module_runtime::PaymentApiKeySnapshot; +pub type PaymentOrderRecord = module_runtime::PaymentOrderSnapshot; +pub type PaymentWebhookDeliveryRecord = module_runtime::PaymentWebhookDeliverySnapshot; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentAppCreateRecordInput { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyCreateRecordInput { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub key_hash: String, + pub scopes: Vec, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentAppUpdateRecordInput { + pub app_id: String, + pub owner_user_id: String, + pub enabled: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyRevokeRecordInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentApiKeyAuthenticateRecordInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderCreateRecordInput { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: module_runtime::PaymentProvider, + pub checkout_token: String, + pub now_micros: i64, + pub expires_at_micros: i64, + pub callback_url: Option, + pub callback_signing_key_hash: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderGetRecordInput { + pub order_id: String, + pub owner_user_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderListRecordInput { + pub app_id: Option, + pub owner_user_id: String, + pub limit: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderMarkPaidRecordInput { + pub order_id: String, + pub provider_trade_no: String, + pub amount_cents: u64, + pub paid_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderProviderCodeRecordInput { + pub order_id: String, + pub provider_code_url: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentOrderProviderAttemptRecordInput { + pub order_id: String, + pub attempted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookEnqueueRecordInput { + pub order_id: String, + pub payload_json: String, + pub signature: String, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookClaimRecordInput { + pub worker_id: String, + pub limit: u32, + pub now_micros: i64, + pub lease_expires_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaymentWebhookCompleteRecordInput { + pub delivery_id: String, + pub worker_id: String, + pub success: bool, + pub error_message: Option, + pub now_micros: i64, + pub next_available_at_micros: i64, + pub attempt_count: u32, +} + +impl From for PaymentAppCreateInput { + fn from(input: PaymentAppCreateRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + callback_url: input.callback_url, + now_micros: input.now_micros, + } + } +} + +impl From for PaymentApiKeyCreateInput { + fn from(input: PaymentApiKeyCreateRecordInput) -> Self { + Self { + key_id: input.key_id, + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + key_prefix: input.key_prefix, + key_hash: input.key_hash, + scopes_json: serde_json::to_string(&input.scopes).unwrap_or_else(|_| "[]".to_string()), + now_micros: input.now_micros, + } + } +} + +impl From for PaymentAppUpdateInput { + fn from(input: PaymentAppUpdateRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + enabled: input.enabled, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for PaymentApiKeyRevokeInput { + fn from(input: PaymentApiKeyRevokeRecordInput) -> Self { + Self { + key_id: input.key_id, + owner_user_id: input.owner_user_id, + revoked_at_micros: input.revoked_at_micros, + } + } +} + +impl From for PaymentApiKeyAuthenticateInput { + fn from(input: PaymentApiKeyAuthenticateRecordInput) -> Self { + Self { + key_hash: input.key_hash, + used_at_micros: input.used_at_micros, + } + } +} + +impl From for PaymentOrderCreateInput { + fn from(input: PaymentOrderCreateRecordInput) -> Self { + Self { + order_id: input.order_id, + app_id: input.app_id, + owner_user_id: input.owner_user_id, + merchant_order_id: input.merchant_order_id, + idempotency_lookup: input.idempotency_lookup, + title: input.title, + items_json: input.items_json, + amount_cents: input.amount_cents, + currency: input.currency, + provider: map_payment_provider(input.provider), + checkout_token: input.checkout_token, + now_micros: input.now_micros, + expires_at_micros: input.expires_at_micros, + callback_url: input.callback_url, + callback_signing_key_hash: input.callback_signing_key_hash, + } + } +} + +impl From for PaymentOrderGetInput { + fn from(input: PaymentOrderGetRecordInput) -> Self { + Self { + order_id: input.order_id, + owner_user_id: input.owner_user_id, + } + } +} + +impl From for PaymentOrderListInput { + fn from(input: PaymentOrderListRecordInput) -> Self { + Self { + app_id: input.app_id, + owner_user_id: input.owner_user_id, + limit: input.limit, + } + } +} + +impl From for PaymentOrderMarkPaidInput { + fn from(input: PaymentOrderMarkPaidRecordInput) -> Self { + Self { + order_id: input.order_id, + provider_trade_no: input.provider_trade_no, + amount_cents: input.amount_cents, + paid_at_micros: input.paid_at_micros, + } + } +} + +impl From for PaymentOrderProviderCodeInput { + fn from(input: PaymentOrderProviderCodeRecordInput) -> Self { + Self { + order_id: input.order_id, + provider_code_url: input.provider_code_url, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for PaymentOrderProviderAttemptInput { + fn from(input: PaymentOrderProviderAttemptRecordInput) -> Self { + Self { + order_id: input.order_id, + attempted_at_micros: input.attempted_at_micros, + } + } +} + +impl From for PaymentWebhookEnqueueInput { + fn from(input: PaymentWebhookEnqueueRecordInput) -> Self { + Self { + order_id: input.order_id, + payload_json: input.payload_json, + signature: input.signature, + now_micros: input.now_micros, + } + } +} + +impl From for PaymentWebhookClaimInput { + fn from(input: PaymentWebhookClaimRecordInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + now_micros: input.now_micros, + lease_expires_at_micros: input.lease_expires_at_micros, + } + } +} + +impl From for PaymentWebhookCompleteInput { + fn from(input: PaymentWebhookCompleteRecordInput) -> Self { + Self { + delivery_id: input.delivery_id, + worker_id: input.worker_id, + success: input.success, + error_message: input.error_message, + now_micros: input.now_micros, + next_available_at_micros: input.next_available_at_micros, + attempt_count: input.attempt_count, + } + } +} + +pub(crate) fn map_payment_app_result( + result: PaymentAppProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .app + .map(map_payment_app_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付应用快照")) +} + +pub(crate) fn map_payment_app_list_result( + result: PaymentAppProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .apps + .into_iter() + .map(map_payment_app_snapshot) + .collect()) +} + +pub(crate) fn map_payment_api_key_result( + result: PaymentAppProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .key + .map(map_payment_api_key_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付 API Key 快照")) +} + +pub(crate) fn map_payment_api_key_list_result( + result: PaymentAppProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .keys + .into_iter() + .map(map_payment_api_key_snapshot) + .collect()) +} + +pub(crate) fn map_payment_authenticate_result( + result: PaymentAppProcedureResult, +) -> Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let app = result + .app + .map(map_payment_app_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付应用快照"))?; + let key = result + .key + .map(map_payment_api_key_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付 API Key 快照"))?; + Ok((app, key)) +} + +pub(crate) fn map_payment_order_result( + result: PaymentOrderProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .order + .map(map_payment_order_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付订单快照")) +} + +pub(crate) fn map_payment_order_list_result( + result: PaymentOrderProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .orders + .into_iter() + .map(map_payment_order_snapshot) + .collect()) +} + +fn map_payment_app_snapshot(snapshot: PaymentAppSnapshot) -> PaymentAppRecord { + module_runtime::PaymentAppSnapshot { + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + name: snapshot.name, + callback_url: snapshot.callback_url, + enabled: snapshot.enabled, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_api_key_snapshot(snapshot: PaymentApiKeySnapshot) -> PaymentApiKeyRecord { + module_runtime::PaymentApiKeySnapshot { + key_id: snapshot.key_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + name: snapshot.name, + key_prefix: snapshot.key_prefix, + scopes_json: snapshot.scopes_json, + created_at_micros: snapshot.created_at_micros, + last_used_at_micros: snapshot.last_used_at_micros, + revoked_at_micros: snapshot.revoked_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_order_snapshot(snapshot: PaymentOrderSnapshot) -> PaymentOrderRecord { + module_runtime::PaymentOrderSnapshot { + order_id: snapshot.order_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + merchant_order_id: snapshot.merchant_order_id, + idempotency_lookup: snapshot.idempotency_lookup, + title: snapshot.title, + items_json: snapshot.items_json, + amount_cents: snapshot.amount_cents, + currency: snapshot.currency, + provider: map_payment_provider_back(snapshot.provider), + provider_trade_no: snapshot.provider_trade_no, + provider_code_url: snapshot.provider_code_url, + callback_url: snapshot.callback_url, + callback_signing_key_hash: snapshot.callback_signing_key_hash, + provider_attempted_at_micros: snapshot.provider_attempted_at_micros, + checkout_token: snapshot.checkout_token, + status: map_payment_order_status_back(snapshot.status), + created_at_micros: snapshot.created_at_micros, + expires_at_micros: snapshot.expires_at_micros, + paid_at_micros: snapshot.paid_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +fn map_payment_provider(value: module_runtime::PaymentProvider) -> PaymentProvider { + match value { + module_runtime::PaymentProvider::WechatNative => PaymentProvider::WechatNative, + } +} + +fn map_payment_provider_back(value: PaymentProvider) -> module_runtime::PaymentProvider { + match value { + PaymentProvider::WechatNative => module_runtime::PaymentProvider::WechatNative, + } +} + +fn map_payment_order_status_back(value: PaymentOrderStatus) -> module_runtime::PaymentOrderStatus { + match value { + PaymentOrderStatus::Pending => module_runtime::PaymentOrderStatus::Pending, + PaymentOrderStatus::Paying => module_runtime::PaymentOrderStatus::Paying, + PaymentOrderStatus::Paid => module_runtime::PaymentOrderStatus::Paid, + PaymentOrderStatus::Expired => module_runtime::PaymentOrderStatus::Expired, + PaymentOrderStatus::Closed => module_runtime::PaymentOrderStatus::Closed, + PaymentOrderStatus::Refunded => module_runtime::PaymentOrderStatus::Refunded, + } +} + +pub(crate) fn map_payment_webhook_enqueue_result( + result: PaymentWebhookProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result.delivery.map(map_payment_webhook_snapshot)) +} + +pub(crate) fn map_payment_webhook_claim_result( + result: PaymentWebhookProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .deliveries + .into_iter() + .map(map_payment_webhook_snapshot) + .collect()) +} + +pub(crate) fn map_payment_webhook_complete_result( + result: PaymentWebhookProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .delivery + .map(map_payment_webhook_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("支付回调投递快照")) +} + +fn map_payment_webhook_snapshot( + snapshot: PaymentWebhookDeliverySnapshot, +) -> PaymentWebhookDeliveryRecord { + module_runtime::PaymentWebhookDeliverySnapshot { + delivery_id: snapshot.delivery_id, + order_id: snapshot.order_id, + app_id: snapshot.app_id, + owner_user_id: snapshot.owner_user_id, + callback_url: snapshot.callback_url, + payload_json: snapshot.payload_json, + signature: snapshot.signature, + status: snapshot.status, + attempt_count: snapshot.attempt_count, + available_at_micros: snapshot.available_at_micros, + lease_worker_id: snapshot.lease_worker_id, + lease_expires_at_micros: snapshot.lease_expires_at_micros, + last_error_message: snapshot.last_error_message, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} diff --git a/server-rs/crates/spacetime-client/src/active/payment.rs b/server-rs/crates/spacetime-client/src/active/payment.rs new file mode 100644 index 000000000..8d1d767e6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/active/payment.rs @@ -0,0 +1,324 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_payment_app( + &self, + input: PaymentAppCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_app", move |connection, sender| { + connection + .procedures() + .create_payment_app_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_payment_apps( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let input = PaymentAppListInput { owner_user_id }; + self.call_after_connect("list_payment_apps", move |connection, sender| { + connection + .procedures() + .list_payment_apps_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn update_payment_app( + &self, + input: PaymentAppUpdateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("update_payment_app", move |connection, sender| { + connection + .procedures() + .update_payment_app_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_app_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn list_payment_api_keys( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let input = PaymentApiKeyListInput { owner_user_id }; + self.call_after_connect("list_payment_api_keys", move |connection, sender| { + connection + .procedures() + .list_payment_api_keys_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn create_payment_api_key( + &self, + input: PaymentApiKeyCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_api_key", move |connection, sender| { + connection + .procedures() + .create_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn revoke_payment_api_key( + &self, + input: PaymentApiKeyRevokeRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("revoke_payment_api_key", move |connection, sender| { + connection + .procedures() + .revoke_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_api_key_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn authenticate_payment_api_key( + &self, + input: PaymentApiKeyAuthenticateRecordInput, + ) -> Result<(PaymentAppRecord, PaymentApiKeyRecord), SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("authenticate_payment_api_key", move |connection, sender| { + connection + .procedures() + .authenticate_payment_api_key_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_authenticate_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn create_payment_order( + &self, + input: PaymentOrderCreateRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("create_payment_order", move |connection, sender| { + connection + .procedures() + .create_payment_order_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_payment_order( + &self, + input: PaymentOrderGetRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("get_payment_order", move |connection, sender| { + connection + .procedures() + .get_payment_order_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn get_payment_order_by_checkout_token( + &self, + checkout_token: String, + ) -> Result { + let input = PaymentOrderCheckoutGetInput { checkout_token }; + self.call_after_connect( + "get_payment_order_by_checkout_token", + move |connection, sender| { + connection + .procedures() + .get_payment_order_by_checkout_token_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_payment_orders( + &self, + input: PaymentOrderListRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("list_payment_orders", move |connection, sender| { + connection + .procedures() + .list_payment_orders_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_list_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn mark_payment_order_paid( + &self, + input: PaymentOrderMarkPaidRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("mark_payment_order_paid", move |connection, sender| { + connection + .procedures() + .mark_payment_order_paid_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn set_payment_order_provider_code( + &self, + input: PaymentOrderProviderCodeRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect( + "set_payment_order_provider_code", + move |connection, sender| { + connection + .procedures() + .set_payment_order_provider_code_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + pub async fn mark_payment_order_provider_attempted( + &self, + input: PaymentOrderProviderAttemptRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect( + "mark_payment_order_provider_attempted", + move |connection, sender| { + connection + .procedures() + .mark_payment_order_provider_attempted_and_return_then( + input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_order_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn enqueue_payment_webhook( + &self, + input: PaymentWebhookEnqueueRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("enqueue_payment_webhook", move |connection, sender| { + connection + .procedures() + .enqueue_payment_webhook_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_enqueue_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn claim_payment_webhooks( + &self, + input: PaymentWebhookClaimRecordInput, + ) -> Result, SpacetimeClientError> { + let input = input.into(); + self.call_after_connect("claim_payment_webhooks", move |connection, sender| { + connection + .procedures() + .claim_payment_webhooks_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_claim_result); + send_once(&sender, mapped); + }); + }) + .await + } + + pub async fn complete_payment_webhook( + &self, + input: PaymentWebhookCompleteRecordInput, + ) -> Result { + let input = input.into(); + self.call_after_connect("complete_payment_webhook", move |connection, sender| { + connection + .procedures() + .complete_payment_webhook_and_return_then(input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_payment_webhook_complete_result); + send_once(&sender, mapped); + }); + }) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index f34c083e7..166d75a6c 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -819,6 +819,28 @@ impl SpacetimeClient { .await } + pub async fn get_editor_asset_folder( + &self, + input: EditorAssetFolderGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_editor_asset_folder_and_return", + move |connection, sender| { + connection + .procedures() + .get_editor_asset_folder_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_asset_folder_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn create_editor_asset_folder( &self, input: EditorAssetFolderCreateRecordInput, diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 1465fdd4b..928f87ec3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -136,6 +136,7 @@ pub mod auth_store_projection_user_type; pub mod auth_store_projection_view_type; pub mod auth_store_projection_wechat_state_type; pub mod authenticate_external_api_key_and_return_procedure; +pub mod authenticate_payment_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; pub mod backfill_editor_canvas_layout_and_return_procedure; pub mod backfill_external_generation_job_summaries_and_return_procedure; @@ -144,6 +145,7 @@ pub mod bind_asset_object_to_entity_reducer; pub mod cancel_ai_task_and_return_procedure; pub mod cancel_game_distribution_version_and_return_procedure; pub mod claim_external_generation_jobs_and_return_procedure; +pub mod claim_payment_webhooks_and_return_procedure; pub mod claim_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod clean_editor_image_asset_kind_and_return_procedure; @@ -154,6 +156,7 @@ pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; pub mod complete_editor_asset_group_cohort_and_return_procedure; pub mod complete_external_generation_job_and_return_procedure; +pub mod complete_payment_webhook_and_return_procedure; pub mod complete_profile_recharge_order_expiration_schedule_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; @@ -171,6 +174,9 @@ pub mod create_error_report_and_return_procedure; pub mod create_external_api_key_and_return_procedure; pub mod create_game_distribution_game_and_return_procedure; pub mod create_game_distribution_version_and_return_procedure; +pub mod create_payment_api_key_and_return_procedure; +pub mod create_payment_app_and_return_procedure; +pub mod create_payment_order_and_return_procedure; pub mod create_profile_recharge_order_and_return_procedure; pub mod creation_entry_config_table; pub mod creation_entry_config_type; @@ -210,6 +216,7 @@ pub mod editor_asset_create_input_type; pub mod editor_asset_delete_input_type; pub mod editor_asset_folder_create_input_type; pub mod editor_asset_folder_delete_input_type; +pub mod editor_asset_folder_get_input_type; pub mod editor_asset_folder_procedure_result_type; pub mod editor_asset_folder_snapshot_type; pub mod editor_asset_folder_table; @@ -338,6 +345,7 @@ pub mod editor_spritesheet_slice_batch_persist_result_type; pub mod editor_spritesheet_slice_persist_item_input_type; pub mod editor_spritesheet_slice_persisted_item_type; pub mod enqueue_external_generation_job_and_return_procedure; +pub mod enqueue_payment_webhook_and_return_procedure; pub mod enqueue_profile_wallet_refund_outbox_and_return_procedure; pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod error_report_create_input_type; @@ -462,6 +470,7 @@ pub mod get_asset_object_by_id_and_return_procedure; pub mod get_asset_object_by_location_and_return_procedure; pub mod get_asset_read_access_by_location_and_return_procedure; pub mod get_editor_agent_conversation_and_return_procedure; +pub mod get_editor_asset_folder_and_return_procedure; pub mod get_editor_asset_library_and_return_procedure; pub mod get_editor_generation_pricing_config_and_return_procedure; pub mod get_editor_project_and_return_procedure; @@ -477,6 +486,8 @@ pub mod get_game_distribution_my_review_and_return_procedure; pub mod get_game_distribution_version_and_return_procedure; pub mod get_llm_router_account_and_return_procedure; pub mod get_owner_game_distribution_version_and_return_procedure; +pub mod get_payment_order_and_return_procedure; +pub mod get_payment_order_by_checkout_token_and_return_procedure; pub mod get_profile_dashboard_procedure; pub mod get_profile_membership_snapshot_and_return_procedure; pub mod get_profile_membership_upgrade_quote_procedure; @@ -511,6 +522,9 @@ pub mod list_game_distribution_review_games_and_return_procedure; pub mod list_game_distribution_reviews_and_return_procedure; pub mod list_game_distribution_user_reviews_and_return_procedure; pub mod list_owner_game_distribution_games_and_return_procedure; +pub mod list_payment_api_keys_and_return_procedure; +pub mod list_payment_apps_and_return_procedure; +pub mod list_payment_orders_and_return_procedure; pub mod list_profile_recharge_refund_holds_for_reconciliation_procedure; pub mod list_profile_recharge_refunds_for_reconciliation_procedure; pub mod list_profile_wallet_ledger_procedure; @@ -530,10 +544,46 @@ pub mod llm_router_billing_checkpoint_type; pub mod llm_router_quota_settlement_input_type; pub mod llm_router_quota_settlement_result_type; pub mod mark_editor_showcase_asset_refunded_and_return_procedure; +pub mod mark_payment_order_paid_and_return_procedure; +pub mod mark_payment_order_provider_attempted_and_return_procedure; pub mod mark_profile_recharge_order_expiration_checked_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod moderate_game_distribution_user_review_and_return_procedure; pub mod normalize_editor_character_animation_metadata_and_return_procedure; +pub mod payment_api_key_authenticate_input_type; +pub mod payment_api_key_create_input_type; +pub mod payment_api_key_list_input_type; +pub mod payment_api_key_revoke_input_type; +pub mod payment_api_key_snapshot_type; +pub mod payment_api_key_table; +pub mod payment_api_key_type; +pub mod payment_app_create_input_type; +pub mod payment_app_list_input_type; +pub mod payment_app_procedure_result_type; +pub mod payment_app_snapshot_type; +pub mod payment_app_table; +pub mod payment_app_type; +pub mod payment_app_update_input_type; +pub mod payment_order_checkout_get_input_type; +pub mod payment_order_create_input_type; +pub mod payment_order_get_input_type; +pub mod payment_order_list_input_type; +pub mod payment_order_mark_paid_input_type; +pub mod payment_order_procedure_result_type; +pub mod payment_order_provider_attempt_input_type; +pub mod payment_order_provider_code_input_type; +pub mod payment_order_snapshot_type; +pub mod payment_order_status_type; +pub mod payment_order_table; +pub mod payment_order_type; +pub mod payment_provider_type; +pub mod payment_webhook_claim_input_type; +pub mod payment_webhook_complete_input_type; +pub mod payment_webhook_delivery_snapshot_type; +pub mod payment_webhook_delivery_table; +pub mod payment_webhook_delivery_type; +pub mod payment_webhook_enqueue_input_type; +pub mod payment_webhook_procedure_result_type; pub mod persist_editor_generation_result_and_return_procedure; pub mod persist_editor_pixel_art_result_and_return_procedure; pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; @@ -629,6 +679,7 @@ pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure; pub mod restore_game_distribution_game_and_return_procedure; pub mod revoke_database_migration_operator_procedure; pub mod revoke_external_api_key_and_return_procedure; +pub mod revoke_payment_api_key_and_return_procedure; pub mod rollback_editor_canvas_layout_and_return_procedure; pub mod rotate_editor_generation_runtime_service_identity_and_return_procedure; pub mod runtime_browse_history_theme_mode_type; @@ -794,6 +845,7 @@ pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod save_game_distribution_my_review_and_return_procedure; pub mod seed_analytics_date_dimensions_reducer; pub mod set_editor_showcase_asset_like_for_viewer_and_return_procedure; +pub mod set_payment_order_provider_code_and_return_procedure; pub mod settle_llm_router_quota_and_return_procedure; pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; @@ -815,6 +867,7 @@ pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_editor_showcase_asset_display_and_return_procedure; pub mod update_error_report_and_return_procedure; pub mod update_external_generation_job_phase_and_return_procedure; +pub mod update_payment_app_and_return_procedure; pub mod upload_agc_analytics_batch_procedure; pub mod upsert_editor_generation_pricing_config_and_return_procedure; pub mod upsert_editor_showcase_campaign_config_and_return_procedure; @@ -957,6 +1010,7 @@ pub use auth_store_projection_user_type::AuthStoreProjectionUser; pub use auth_store_projection_view_type::AuthStoreProjectionView; pub use auth_store_projection_wechat_state_type::AuthStoreProjectionWechatState; pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return; +pub use authenticate_payment_api_key_and_return_procedure::authenticate_payment_api_key_and_return; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; pub use backfill_editor_canvas_layout_and_return_procedure::backfill_editor_canvas_layout_and_return; pub use backfill_external_generation_job_summaries_and_return_procedure::backfill_external_generation_job_summaries_and_return; @@ -965,6 +1019,7 @@ pub use bind_asset_object_to_entity_reducer::bind_asset_object_to_entity; pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return; pub use cancel_game_distribution_version_and_return_procedure::cancel_game_distribution_version_and_return; pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return; +pub use claim_payment_webhooks_and_return_procedure::claim_payment_webhooks_and_return; pub use claim_profile_recharge_order_expiration_schedule_and_return_procedure::claim_profile_recharge_order_expiration_schedule_and_return; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use clean_editor_image_asset_kind_and_return_procedure::clean_editor_image_asset_kind_and_return; @@ -975,6 +1030,7 @@ pub use complete_ai_stage_and_return_procedure::complete_ai_stage_and_return; pub use complete_ai_task_and_return_procedure::complete_ai_task_and_return; pub use complete_editor_asset_group_cohort_and_return_procedure::complete_editor_asset_group_cohort_and_return; pub use complete_external_generation_job_and_return_procedure::complete_external_generation_job_and_return; +pub use complete_payment_webhook_and_return_procedure::complete_payment_webhook_and_return; pub use complete_profile_recharge_order_expiration_schedule_and_return_procedure::complete_profile_recharge_order_expiration_schedule_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; @@ -992,6 +1048,9 @@ pub use create_error_report_and_return_procedure::create_error_report_and_return pub use create_external_api_key_and_return_procedure::create_external_api_key_and_return; pub use create_game_distribution_game_and_return_procedure::create_game_distribution_game_and_return; pub use create_game_distribution_version_and_return_procedure::create_game_distribution_version_and_return; +pub use create_payment_api_key_and_return_procedure::create_payment_api_key_and_return; +pub use create_payment_app_and_return_procedure::create_payment_app_and_return; +pub use create_payment_order_and_return_procedure::create_payment_order_and_return; pub use create_profile_recharge_order_and_return_procedure::create_profile_recharge_order_and_return; pub use creation_entry_config_table::*; pub use creation_entry_config_type::CreationEntryConfig; @@ -1031,6 +1090,7 @@ pub use editor_asset_create_input_type::EditorAssetCreateInput; pub use editor_asset_delete_input_type::EditorAssetDeleteInput; pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput; pub use editor_asset_folder_delete_input_type::EditorAssetFolderDeleteInput; +pub use editor_asset_folder_get_input_type::EditorAssetFolderGetInput; pub use editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult; pub use editor_asset_folder_snapshot_type::EditorAssetFolderSnapshot; pub use editor_asset_folder_table::*; @@ -1159,6 +1219,7 @@ pub use editor_spritesheet_slice_batch_persist_result_type::EditorSpritesheetSli pub use editor_spritesheet_slice_persist_item_input_type::EditorSpritesheetSlicePersistItemInput; pub use editor_spritesheet_slice_persisted_item_type::EditorSpritesheetSlicePersistedItem; pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; +pub use enqueue_payment_webhook_and_return_procedure::enqueue_payment_webhook_and_return; pub use enqueue_profile_wallet_refund_outbox_and_return_procedure::enqueue_profile_wallet_refund_outbox_and_return; pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use error_report_create_input_type::ErrorReportCreateInput; @@ -1283,6 +1344,7 @@ pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_ pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return; +pub use get_editor_asset_folder_and_return_procedure::get_editor_asset_folder_and_return; pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return; pub use get_editor_generation_pricing_config_and_return_procedure::get_editor_generation_pricing_config_and_return; pub use get_editor_project_and_return_procedure::get_editor_project_and_return; @@ -1298,6 +1360,8 @@ pub use get_game_distribution_my_review_and_return_procedure::get_game_distribut pub use get_game_distribution_version_and_return_procedure::get_game_distribution_version_and_return; pub use get_llm_router_account_and_return_procedure::get_llm_router_account_and_return; pub use get_owner_game_distribution_version_and_return_procedure::get_owner_game_distribution_version_and_return; +pub use get_payment_order_and_return_procedure::get_payment_order_and_return; +pub use get_payment_order_by_checkout_token_and_return_procedure::get_payment_order_by_checkout_token_and_return; pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_membership_snapshot_and_return_procedure::get_profile_membership_snapshot_and_return; pub use get_profile_membership_upgrade_quote_procedure::get_profile_membership_upgrade_quote; @@ -1332,6 +1396,9 @@ pub use list_game_distribution_review_games_and_return_procedure::list_game_dist pub use list_game_distribution_reviews_and_return_procedure::list_game_distribution_reviews_and_return; pub use list_game_distribution_user_reviews_and_return_procedure::list_game_distribution_user_reviews_and_return; pub use list_owner_game_distribution_games_and_return_procedure::list_owner_game_distribution_games_and_return; +pub use list_payment_api_keys_and_return_procedure::list_payment_api_keys_and_return; +pub use list_payment_apps_and_return_procedure::list_payment_apps_and_return; +pub use list_payment_orders_and_return_procedure::list_payment_orders_and_return; pub use list_profile_recharge_refund_holds_for_reconciliation_procedure::list_profile_recharge_refund_holds_for_reconciliation; pub use list_profile_recharge_refunds_for_reconciliation_procedure::list_profile_recharge_refunds_for_reconciliation; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; @@ -1351,10 +1418,46 @@ pub use llm_router_billing_checkpoint_type::LlmRouterBillingCheckpoint; pub use llm_router_quota_settlement_input_type::LlmRouterQuotaSettlementInput; pub use llm_router_quota_settlement_result_type::LlmRouterQuotaSettlementResult; pub use mark_editor_showcase_asset_refunded_and_return_procedure::mark_editor_showcase_asset_refunded_and_return; +pub use mark_payment_order_paid_and_return_procedure::mark_payment_order_paid_and_return; +pub use mark_payment_order_provider_attempted_and_return_procedure::mark_payment_order_provider_attempted_and_return; pub use mark_profile_recharge_order_expiration_checked_procedure::mark_profile_recharge_order_expiration_checked; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use moderate_game_distribution_user_review_and_return_procedure::moderate_game_distribution_user_review_and_return; pub use normalize_editor_character_animation_metadata_and_return_procedure::normalize_editor_character_animation_metadata_and_return; +pub use payment_api_key_authenticate_input_type::PaymentApiKeyAuthenticateInput; +pub use payment_api_key_create_input_type::PaymentApiKeyCreateInput; +pub use payment_api_key_list_input_type::PaymentApiKeyListInput; +pub use payment_api_key_revoke_input_type::PaymentApiKeyRevokeInput; +pub use payment_api_key_snapshot_type::PaymentApiKeySnapshot; +pub use payment_api_key_table::*; +pub use payment_api_key_type::PaymentApiKey; +pub use payment_app_create_input_type::PaymentAppCreateInput; +pub use payment_app_list_input_type::PaymentAppListInput; +pub use payment_app_procedure_result_type::PaymentAppProcedureResult; +pub use payment_app_snapshot_type::PaymentAppSnapshot; +pub use payment_app_table::*; +pub use payment_app_type::PaymentApp; +pub use payment_app_update_input_type::PaymentAppUpdateInput; +pub use payment_order_checkout_get_input_type::PaymentOrderCheckoutGetInput; +pub use payment_order_create_input_type::PaymentOrderCreateInput; +pub use payment_order_get_input_type::PaymentOrderGetInput; +pub use payment_order_list_input_type::PaymentOrderListInput; +pub use payment_order_mark_paid_input_type::PaymentOrderMarkPaidInput; +pub use payment_order_procedure_result_type::PaymentOrderProcedureResult; +pub use payment_order_provider_attempt_input_type::PaymentOrderProviderAttemptInput; +pub use payment_order_provider_code_input_type::PaymentOrderProviderCodeInput; +pub use payment_order_snapshot_type::PaymentOrderSnapshot; +pub use payment_order_status_type::PaymentOrderStatus; +pub use payment_order_table::*; +pub use payment_order_type::PaymentOrder; +pub use payment_provider_type::PaymentProvider; +pub use payment_webhook_claim_input_type::PaymentWebhookClaimInput; +pub use payment_webhook_complete_input_type::PaymentWebhookCompleteInput; +pub use payment_webhook_delivery_snapshot_type::PaymentWebhookDeliverySnapshot; +pub use payment_webhook_delivery_table::*; +pub use payment_webhook_delivery_type::PaymentWebhookDelivery; +pub use payment_webhook_enqueue_input_type::PaymentWebhookEnqueueInput; +pub use payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; pub use persist_editor_generation_result_and_return_procedure::persist_editor_generation_result_and_return; pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return; pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; @@ -1450,6 +1553,7 @@ pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::reso pub use restore_game_distribution_game_and_return_procedure::restore_game_distribution_game_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return; +pub use revoke_payment_api_key_and_return_procedure::revoke_payment_api_key_and_return; pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return; pub use rotate_editor_generation_runtime_service_identity_and_return_procedure::rotate_editor_generation_runtime_service_identity_and_return; pub use runtime_browse_history_theme_mode_type::RuntimeBrowseHistoryThemeMode; @@ -1615,6 +1719,7 @@ pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project pub use save_game_distribution_my_review_and_return_procedure::save_game_distribution_my_review_and_return; pub use seed_analytics_date_dimensions_reducer::seed_analytics_date_dimensions; pub use set_editor_showcase_asset_like_for_viewer_and_return_procedure::set_editor_showcase_asset_like_for_viewer_and_return; +pub use set_payment_order_provider_code_and_return_procedure::set_payment_order_provider_code_and_return; pub use settle_llm_router_quota_and_return_procedure::settle_llm_router_quota_and_return; pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; @@ -1636,6 +1741,7 @@ pub use update_editor_project_resource_showcase_and_return_procedure::update_edi pub use update_editor_showcase_asset_display_and_return_procedure::update_editor_showcase_asset_display_and_return; pub use update_error_report_and_return_procedure::update_error_report_and_return; pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; +pub use update_payment_app_and_return_procedure::update_payment_app_and_return; pub use upload_agc_analytics_batch_procedure::upload_agc_analytics_batch; pub use upsert_editor_generation_pricing_config_and_return_procedure::upsert_editor_generation_pricing_config_and_return; pub use upsert_editor_showcase_campaign_config_and_return_procedure::upsert_editor_showcase_campaign_config_and_return; @@ -1808,6 +1914,10 @@ pub struct DbUpdate { game_distribution_version: __sdk::TableUpdate, llm_router_account: __sdk::TableUpdate, llm_router_billing_checkpoint: __sdk::TableUpdate, + payment_api_key: __sdk::TableUpdate, + payment_app: __sdk::TableUpdate, + payment_order: __sdk::TableUpdate, + payment_webhook_delivery: __sdk::TableUpdate, profile_code_operation: __sdk::TableUpdate, profile_daily_free_points: __sdk::TableUpdate, profile_dashboard_state: __sdk::TableUpdate, @@ -2040,6 +2150,18 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "llm_router_billing_checkpoint" => db_update.llm_router_billing_checkpoint.append( llm_router_billing_checkpoint_table::parse_table_update(table_update)?, ), + "payment_api_key" => db_update + .payment_api_key + .append(payment_api_key_table::parse_table_update(table_update)?), + "payment_app" => db_update + .payment_app + .append(payment_app_table::parse_table_update(table_update)?), + "payment_order" => db_update + .payment_order + .append(payment_order_table::parse_table_update(table_update)?), + "payment_webhook_delivery" => db_update.payment_webhook_delivery.append( + payment_webhook_delivery_table::parse_table_update(table_update)?, + ), "profile_code_operation" => db_update.profile_code_operation.append( profile_code_operation_table::parse_table_update(table_update)?, ), @@ -2454,6 +2576,21 @@ impl __sdk::DbUpdate for DbUpdate { &self.llm_router_billing_checkpoint, ) .with_updates_by_pk(|row| &row.account_key); + diff.payment_api_key = cache + .apply_diff_to_table::("payment_api_key", &self.payment_api_key) + .with_updates_by_pk(|row| &row.key_id); + diff.payment_app = cache + .apply_diff_to_table::("payment_app", &self.payment_app) + .with_updates_by_pk(|row| &row.app_id); + diff.payment_order = cache + .apply_diff_to_table::("payment_order", &self.payment_order) + .with_updates_by_pk(|row| &row.order_id); + diff.payment_webhook_delivery = cache + .apply_diff_to_table::( + "payment_webhook_delivery", + &self.payment_webhook_delivery, + ) + .with_updates_by_pk(|row| &row.delivery_id); diff.profile_code_operation = cache .apply_diff_to_table::( "profile_code_operation", @@ -2821,6 +2958,18 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_billing_checkpoint" => db_update .llm_router_billing_checkpoint .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_api_key" => db_update + .payment_api_key + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_app" => db_update + .payment_app + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_order" => db_update + .payment_order + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "payment_webhook_delivery" => db_update + .payment_webhook_delivery + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3098,6 +3247,18 @@ impl __sdk::DbUpdate for DbUpdate { "llm_router_billing_checkpoint" => db_update .llm_router_billing_checkpoint .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_api_key" => db_update + .payment_api_key + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_app" => db_update + .payment_app + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_order" => db_update + .payment_order + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "payment_webhook_delivery" => db_update + .payment_webhook_delivery + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_code_operation" => db_update .profile_code_operation .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3281,6 +3442,10 @@ pub struct AppliedDiff<'r> { game_distribution_version: __sdk::TableAppliedDiff<'r, GameDistributionVersion>, llm_router_account: __sdk::TableAppliedDiff<'r, LlmRouterAccount>, llm_router_billing_checkpoint: __sdk::TableAppliedDiff<'r, LlmRouterBillingCheckpoint>, + payment_api_key: __sdk::TableAppliedDiff<'r, PaymentApiKey>, + payment_app: __sdk::TableAppliedDiff<'r, PaymentApp>, + payment_order: __sdk::TableAppliedDiff<'r, PaymentOrder>, + payment_webhook_delivery: __sdk::TableAppliedDiff<'r, PaymentWebhookDelivery>, profile_code_operation: __sdk::TableAppliedDiff<'r, ProfileCodeOperation>, profile_daily_free_points: __sdk::TableAppliedDiff<'r, ProfileDailyFreePoints>, profile_dashboard_state: __sdk::TableAppliedDiff<'r, ProfileDashboardState>, @@ -3579,6 +3744,22 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.llm_router_billing_checkpoint, event, ); + callbacks.invoke_table_row_callbacks::( + "payment_api_key", + &self.payment_api_key, + event, + ); + callbacks.invoke_table_row_callbacks::("payment_app", &self.payment_app, event); + callbacks.invoke_table_row_callbacks::( + "payment_order", + &self.payment_order, + event, + ); + callbacks.invoke_table_row_callbacks::( + "payment_webhook_delivery", + &self.payment_webhook_delivery, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_code_operation", &self.profile_code_operation, @@ -4479,6 +4660,10 @@ impl __sdk::SpacetimeModule for RemoteModule { game_distribution_version_table::register_table(client_cache); llm_router_account_table::register_table(client_cache); llm_router_billing_checkpoint_table::register_table(client_cache); + payment_api_key_table::register_table(client_cache); + payment_app_table::register_table(client_cache); + payment_order_table::register_table(client_cache); + payment_webhook_delivery_table::register_table(client_cache); profile_code_operation_table::register_table(client_cache); profile_daily_free_points_table::register_table(client_cache); profile_dashboard_state_table::register_table(client_cache); @@ -4569,6 +4754,10 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_distribution_version", "llm_router_account", "llm_router_billing_checkpoint", + "payment_api_key", + "payment_app", + "payment_order", + "payment_webhook_delivery", "profile_code_operation", "profile_daily_free_points", "profile_dashboard_state", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..33c26c311 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/authenticate_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_authenticate_input_type::PaymentApiKeyAuthenticateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AuthenticatePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyAuthenticateInput, +} + +impl __sdk::InModule for AuthenticatePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `authenticate_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait authenticate_payment_api_key_and_return { + fn authenticate_payment_api_key_and_return(&self, input: PaymentApiKeyAuthenticateInput) { + self.authenticate_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn authenticate_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyAuthenticateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl authenticate_payment_api_key_and_return for super::RemoteProcedures { + fn authenticate_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyAuthenticateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "authenticate_payment_api_key_and_return", + AuthenticatePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs new file mode 100644 index 000000000..f28ea001f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_payment_webhooks_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_claim_input_type::PaymentWebhookClaimInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimPaymentWebhooksAndReturnArgs { + pub input: PaymentWebhookClaimInput, +} + +impl __sdk::InModule for ClaimPaymentWebhooksAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_payment_webhooks_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_payment_webhooks_and_return { + fn claim_payment_webhooks_and_return(&self, input: PaymentWebhookClaimInput) { + self.claim_payment_webhooks_and_return_then(input, |_, _| {}); + } + + fn claim_payment_webhooks_and_return_then( + &self, + input: PaymentWebhookClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_payment_webhooks_and_return for super::RemoteProcedures { + fn claim_payment_webhooks_and_return_then( + &self, + input: PaymentWebhookClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "claim_payment_webhooks_and_return", + ClaimPaymentWebhooksAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs new file mode 100644 index 000000000..8eb38c881 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_payment_webhook_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_complete_input_type::PaymentWebhookCompleteInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompletePaymentWebhookAndReturnArgs { + pub input: PaymentWebhookCompleteInput, +} + +impl __sdk::InModule for CompletePaymentWebhookAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_payment_webhook_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_payment_webhook_and_return { + fn complete_payment_webhook_and_return(&self, input: PaymentWebhookCompleteInput) { + self.complete_payment_webhook_and_return_then(input, |_, _| {}); + } + + fn complete_payment_webhook_and_return_then( + &self, + input: PaymentWebhookCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_payment_webhook_and_return for super::RemoteProcedures { + fn complete_payment_webhook_and_return_then( + &self, + input: PaymentWebhookCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "complete_payment_webhook_and_return", + CompletePaymentWebhookAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..6cd1a0ca5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_create_input_type::PaymentApiKeyCreateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyCreateInput, +} + +impl __sdk::InModule for CreatePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_api_key_and_return { + fn create_payment_api_key_and_return(&self, input: PaymentApiKeyCreateInput) { + self.create_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn create_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_api_key_and_return for super::RemoteProcedures { + fn create_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "create_payment_api_key_and_return", + CreatePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs new file mode 100644 index 000000000..493fba127 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_app_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_create_input_type::PaymentAppCreateInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentAppAndReturnArgs { + pub input: PaymentAppCreateInput, +} + +impl __sdk::InModule for CreatePaymentAppAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_app_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_app_and_return { + fn create_payment_app_and_return(&self, input: PaymentAppCreateInput) { + self.create_payment_app_and_return_then(input, |_, _| {}); + } + + fn create_payment_app_and_return_then( + &self, + input: PaymentAppCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_app_and_return for super::RemoteProcedures { + fn create_payment_app_and_return_then( + &self, + input: PaymentAppCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "create_payment_app_and_return", + CreatePaymentAppAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs new file mode 100644 index 000000000..e576d93b7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_payment_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_create_input_type::PaymentOrderCreateInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreatePaymentOrderAndReturnArgs { + pub input: PaymentOrderCreateInput, +} + +impl __sdk::InModule for CreatePaymentOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_payment_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_payment_order_and_return { + fn create_payment_order_and_return(&self, input: PaymentOrderCreateInput) { + self.create_payment_order_and_return_then(input, |_, _| {}); + } + + fn create_payment_order_and_return_then( + &self, + input: PaymentOrderCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_payment_order_and_return for super::RemoteProcedures { + fn create_payment_order_and_return_then( + &self, + input: PaymentOrderCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "create_payment_order_and_return", + CreatePaymentOrderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs new file mode 100644 index 000000000..e9514dfb4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_asset_folder_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAssetFolderGetInput { + pub folder_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAssetFolderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs new file mode 100644 index 000000000..91c952715 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_payment_webhook_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_enqueue_input_type::PaymentWebhookEnqueueInput; +use super::payment_webhook_procedure_result_type::PaymentWebhookProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct EnqueuePaymentWebhookAndReturnArgs { + pub input: PaymentWebhookEnqueueInput, +} + +impl __sdk::InModule for EnqueuePaymentWebhookAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `enqueue_payment_webhook_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait enqueue_payment_webhook_and_return { + fn enqueue_payment_webhook_and_return(&self, input: PaymentWebhookEnqueueInput) { + self.enqueue_payment_webhook_and_return_then(input, |_, _| {}); + } + + fn enqueue_payment_webhook_and_return_then( + &self, + input: PaymentWebhookEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl enqueue_payment_webhook_and_return for super::RemoteProcedures { + fn enqueue_payment_webhook_and_return_then( + &self, + input: PaymentWebhookEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentWebhookProcedureResult>( + "enqueue_payment_webhook_and_return", + EnqueuePaymentWebhookAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs new file mode 100644 index 000000000..bc951678a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_asset_folder_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_asset_folder_get_input_type::EditorAssetFolderGetInput; +use super::editor_asset_folder_procedure_result_type::EditorAssetFolderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetEditorAssetFolderAndReturnArgs { + pub input: EditorAssetFolderGetInput, +} + +impl __sdk::InModule for GetEditorAssetFolderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_editor_asset_folder_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_editor_asset_folder_and_return { + fn get_editor_asset_folder_and_return(&self, input: EditorAssetFolderGetInput) { + self.get_editor_asset_folder_and_return_then(input, |_, _| {}); + } + + fn get_editor_asset_folder_and_return_then( + &self, + input: EditorAssetFolderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_editor_asset_folder_and_return for super::RemoteProcedures { + fn get_editor_asset_folder_and_return_then( + &self, + input: EditorAssetFolderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAssetFolderProcedureResult>( + "get_editor_asset_folder_and_return", + GetEditorAssetFolderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs new file mode 100644 index 000000000..981124029 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_get_input_type::PaymentOrderGetInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetPaymentOrderAndReturnArgs { + pub input: PaymentOrderGetInput, +} + +impl __sdk::InModule for GetPaymentOrderAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_payment_order_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_payment_order_and_return { + fn get_payment_order_and_return(&self, input: PaymentOrderGetInput) { + self.get_payment_order_and_return_then(input, |_, _| {}); + } + + fn get_payment_order_and_return_then( + &self, + input: PaymentOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_payment_order_and_return for super::RemoteProcedures { + fn get_payment_order_and_return_then( + &self, + input: PaymentOrderGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "get_payment_order_and_return", + GetPaymentOrderAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs new file mode 100644 index 000000000..eb14d8e86 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_payment_order_by_checkout_token_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_checkout_get_input_type::PaymentOrderCheckoutGetInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetPaymentOrderByCheckoutTokenAndReturnArgs { + pub input: PaymentOrderCheckoutGetInput, +} + +impl __sdk::InModule for GetPaymentOrderByCheckoutTokenAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_payment_order_by_checkout_token_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_payment_order_by_checkout_token_and_return { + fn get_payment_order_by_checkout_token_and_return(&self, input: PaymentOrderCheckoutGetInput) { + self.get_payment_order_by_checkout_token_and_return_then(input, |_, _| {}); + } + + fn get_payment_order_by_checkout_token_and_return_then( + &self, + input: PaymentOrderCheckoutGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_payment_order_by_checkout_token_and_return for super::RemoteProcedures { + fn get_payment_order_by_checkout_token_and_return_then( + &self, + input: PaymentOrderCheckoutGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "get_payment_order_by_checkout_token_and_return", + GetPaymentOrderByCheckoutTokenAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs new file mode 100644 index 000000000..76c904866 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_api_keys_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_list_input_type::PaymentApiKeyListInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentApiKeysAndReturnArgs { + pub input: PaymentApiKeyListInput, +} + +impl __sdk::InModule for ListPaymentApiKeysAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_api_keys_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_api_keys_and_return { + fn list_payment_api_keys_and_return(&self, input: PaymentApiKeyListInput) { + self.list_payment_api_keys_and_return_then(input, |_, _| {}); + } + + fn list_payment_api_keys_and_return_then( + &self, + input: PaymentApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_api_keys_and_return for super::RemoteProcedures { + fn list_payment_api_keys_and_return_then( + &self, + input: PaymentApiKeyListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "list_payment_api_keys_and_return", + ListPaymentApiKeysAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs new file mode 100644 index 000000000..200701e1e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_apps_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_list_input_type::PaymentAppListInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentAppsAndReturnArgs { + pub input: PaymentAppListInput, +} + +impl __sdk::InModule for ListPaymentAppsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_apps_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_apps_and_return { + fn list_payment_apps_and_return(&self, input: PaymentAppListInput) { + self.list_payment_apps_and_return_then(input, |_, _| {}); + } + + fn list_payment_apps_and_return_then( + &self, + input: PaymentAppListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_apps_and_return for super::RemoteProcedures { + fn list_payment_apps_and_return_then( + &self, + input: PaymentAppListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "list_payment_apps_and_return", + ListPaymentAppsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs new file mode 100644 index 000000000..f00f5674d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_payment_orders_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_list_input_type::PaymentOrderListInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListPaymentOrdersAndReturnArgs { + pub input: PaymentOrderListInput, +} + +impl __sdk::InModule for ListPaymentOrdersAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_payment_orders_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_payment_orders_and_return { + fn list_payment_orders_and_return(&self, input: PaymentOrderListInput) { + self.list_payment_orders_and_return_then(input, |_, _| {}); + } + + fn list_payment_orders_and_return_then( + &self, + input: PaymentOrderListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_payment_orders_and_return for super::RemoteProcedures { + fn list_payment_orders_and_return_then( + &self, + input: PaymentOrderListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "list_payment_orders_and_return", + ListPaymentOrdersAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs new file mode 100644 index 000000000..15600aaff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_paid_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_mark_paid_input_type::PaymentOrderMarkPaidInput; +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPaymentOrderPaidAndReturnArgs { + pub input: PaymentOrderMarkPaidInput, +} + +impl __sdk::InModule for MarkPaymentOrderPaidAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_payment_order_paid_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_payment_order_paid_and_return { + fn mark_payment_order_paid_and_return(&self, input: PaymentOrderMarkPaidInput) { + self.mark_payment_order_paid_and_return_then(input, |_, _| {}); + } + + fn mark_payment_order_paid_and_return_then( + &self, + input: PaymentOrderMarkPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_payment_order_paid_and_return for super::RemoteProcedures { + fn mark_payment_order_paid_and_return_then( + &self, + input: PaymentOrderMarkPaidInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "mark_payment_order_paid_and_return", + MarkPaymentOrderPaidAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs new file mode 100644 index 000000000..b533220a7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_payment_order_provider_attempted_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; +use super::payment_order_provider_attempt_input_type::PaymentOrderProviderAttemptInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPaymentOrderProviderAttemptedAndReturnArgs { + pub input: PaymentOrderProviderAttemptInput, +} + +impl __sdk::InModule for MarkPaymentOrderProviderAttemptedAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_payment_order_provider_attempted_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_payment_order_provider_attempted_and_return { + fn mark_payment_order_provider_attempted_and_return( + &self, + input: PaymentOrderProviderAttemptInput, + ) { + self.mark_payment_order_provider_attempted_and_return_then(input, |_, _| {}); + } + + fn mark_payment_order_provider_attempted_and_return_then( + &self, + input: PaymentOrderProviderAttemptInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_payment_order_provider_attempted_and_return for super::RemoteProcedures { + fn mark_payment_order_provider_attempted_and_return_then( + &self, + input: PaymentOrderProviderAttemptInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "mark_payment_order_provider_attempted_and_return", + MarkPaymentOrderProviderAttemptedAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs new file mode 100644 index 000000000..e875b26e1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_authenticate_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyAuthenticateInput { + pub key_hash: String, + pub used_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeyAuthenticateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs new file mode 100644 index 000000000..f197e8a9b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_create_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyCreateInput { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub key_hash: String, + pub scopes_json: String, + pub now_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeyCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs new file mode 100644 index 000000000..71c0c77aa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyListInput { + pub owner_user_id: String, +} + +impl __sdk::InModule for PaymentApiKeyListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs new file mode 100644 index 000000000..0078ab788 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_revoke_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeyRevokeInput { + pub key_id: String, + pub owner_user_id: String, + pub revoked_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeyRevokeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs new file mode 100644 index 000000000..fc27bf52d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_snapshot_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKeySnapshot { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub scopes_json: String, + pub created_at_micros: i64, + pub last_used_at_micros: Option, + pub revoked_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentApiKeySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs new file mode 100644 index 000000000..409f34921 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_table.rs @@ -0,0 +1,259 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_api_key_type::PaymentApiKey; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_api_key`. +/// +/// Obtain a handle from the [`PaymentApiKeyTableAccess::payment_api_key`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_api_key()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().on_insert(...)`. +pub struct PaymentApiKeyTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_api_key`. +pub struct PaymentApiKeyTableAccessor; + +impl __sdk::TableAccessor for PaymentApiKeyTableAccessor { + type Row = PaymentApiKey; + type Handle<'db> = PaymentApiKeyTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_api_key() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_api_key`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentApiKeyTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentApiKeyTableHandle`], which mediates access to the table `payment_api_key`. + fn payment_api_key(&self) -> PaymentApiKeyTableHandle<'_>; +} + +impl PaymentApiKeyTableAccess for super::RemoteTables { + fn payment_api_key(&self) -> PaymentApiKeyTableHandle<'_> { + PaymentApiKeyTableHandle { + imp: self.imp.get_table::("payment_api_key"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentApiKeyInsertCallbackId(__sdk::CallbackId); +pub struct PaymentApiKeyDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentApiKeyTableHandle<'ctx> { + type Row = PaymentApiKey; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentApiKeyTableHandle<'ctx> { + type Row = PaymentApiKey; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentApiKeyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyInsertCallbackId { + PaymentApiKeyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentApiKeyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentApiKeyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyDeleteCallbackId { + PaymentApiKeyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentApiKeyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentApiKeyTableHandle<'ctx> { + type InsertCallbackId = PaymentApiKeyInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyInsertCallbackId { + PaymentApiKeyInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentApiKeyInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentApiKeyTableHandle<'ctx> { + type DeleteCallbackId = PaymentApiKeyDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyDeleteCallbackId { + PaymentApiKeyDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentApiKeyDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentApiKeyUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentApiKeyTableHandle<'ctx> { + type UpdateCallbackId = PaymentApiKeyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyUpdateCallbackId { + PaymentApiKeyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentApiKeyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentApiKeyTableHandle<'ctx> { + type UpdateCallbackId = PaymentApiKeyUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentApiKeyUpdateCallbackId { + PaymentApiKeyUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentApiKeyUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `key_id` unique index on the table `payment_api_key`, +/// which allows point queries on the field of the same name +/// via the [`PaymentApiKeyKeyIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().key_id().find(...)`. +pub struct PaymentApiKeyKeyIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_id` unique index on the table `payment_api_key`. + pub fn key_id(&self) -> PaymentApiKeyKeyIdUnique<'ctx> { + PaymentApiKeyKeyIdUnique { + imp: self.imp.get_unique_constraint::("key_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentApiKeyKeyIdUnique<'ctx> { + /// Find the subscribed row whose `key_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `key_hash` unique index on the table `payment_api_key`, +/// which allows point queries on the field of the same name +/// via the [`PaymentApiKeyKeyHashUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_api_key().key_hash().find(...)`. +pub struct PaymentApiKeyKeyHashUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentApiKeyTableHandle<'ctx> { + /// Get a handle on the `key_hash` unique index on the table `payment_api_key`. + pub fn key_hash(&self) -> PaymentApiKeyKeyHashUnique<'ctx> { + PaymentApiKeyKeyHashUnique { + imp: self.imp.get_unique_constraint::("key_hash"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentApiKeyKeyHashUnique<'ctx> { + /// Find the subscribed row whose `key_hash` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_api_key"); + _table.add_unique_constraint::("key_id", |row| &row.key_id); + _table.add_unique_constraint::("key_hash", |row| &row.key_hash); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentApiKey`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_api_keyQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentApiKey`. + fn payment_api_key(&self) -> __sdk::__query_builder::Table; +} + +impl payment_api_keyQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_api_key(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_api_key") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs new file mode 100644 index 000000000..4a9bc51e7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_api_key_type.rs @@ -0,0 +1,85 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApiKey { + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + pub key_hash: String, + pub scopes_json: String, + pub created_at: __sdk::Timestamp, + pub last_used_at: Option<__sdk::Timestamp>, + pub revoked_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentApiKey { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentApiKey`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentApiKeyCols { + pub key_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub key_prefix: __sdk::__query_builder::Col, + pub key_hash: __sdk::__query_builder::Col, + pub scopes_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub last_used_at: __sdk::__query_builder::Col>, + pub revoked_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentApiKey { + type Cols = PaymentApiKeyCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentApiKeyCols { + key_id: __sdk::__query_builder::Col::new(table_name, "key_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + key_prefix: __sdk::__query_builder::Col::new(table_name, "key_prefix"), + key_hash: __sdk::__query_builder::Col::new(table_name, "key_hash"), + scopes_json: __sdk::__query_builder::Col::new(table_name, "scopes_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + last_used_at: __sdk::__query_builder::Col::new(table_name, "last_used_at"), + revoked_at: __sdk::__query_builder::Col::new(table_name, "revoked_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentApiKey`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentApiKeyIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub key_hash: __sdk::__query_builder::IxCol, + pub key_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentApiKey { + type IxCols = PaymentApiKeyIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentApiKeyIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + key_hash: __sdk::__query_builder::IxCol::new(table_name, "key_hash"), + key_id: __sdk::__query_builder::IxCol::new(table_name, "key_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentApiKey {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs new file mode 100644 index 000000000..ffbc34df5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_create_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppCreateInput { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub now_micros: i64, +} + +impl __sdk::InModule for PaymentAppCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs new file mode 100644 index 000000000..641a4020d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppListInput { + pub owner_user_id: String, +} + +impl __sdk::InModule for PaymentAppListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs new file mode 100644 index 000000000..2bbb8ef04 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_procedure_result_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_snapshot_type::PaymentApiKeySnapshot; +use super::payment_app_snapshot_type::PaymentAppSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppProcedureResult { + pub ok: bool, + pub app: Option, + pub key: Option, + pub apps: Vec, + pub keys: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentAppProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs new file mode 100644 index 000000000..a96a682da --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppSnapshot { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentAppSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs new file mode 100644 index 000000000..74fea42c5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_table.rs @@ -0,0 +1,228 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_app_type::PaymentApp; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_app`. +/// +/// Obtain a handle from the [`PaymentAppTableAccess::payment_app`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_app()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_app().on_insert(...)`. +pub struct PaymentAppTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_app`. +pub struct PaymentAppTableAccessor; + +impl __sdk::TableAccessor for PaymentAppTableAccessor { + type Row = PaymentApp; + type Handle<'db> = PaymentAppTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_app() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_app`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentAppTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentAppTableHandle`], which mediates access to the table `payment_app`. + fn payment_app(&self) -> PaymentAppTableHandle<'_>; +} + +impl PaymentAppTableAccess for super::RemoteTables { + fn payment_app(&self) -> PaymentAppTableHandle<'_> { + PaymentAppTableHandle { + imp: self.imp.get_table::("payment_app"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentAppInsertCallbackId(__sdk::CallbackId); +pub struct PaymentAppDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentAppTableHandle<'ctx> { + type Row = PaymentApp; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentAppTableHandle<'ctx> { + type Row = PaymentApp; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentAppInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppInsertCallbackId { + PaymentAppInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentAppInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentAppDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppDeleteCallbackId { + PaymentAppDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentAppDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentAppTableHandle<'ctx> { + type InsertCallbackId = PaymentAppInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppInsertCallbackId { + PaymentAppInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentAppInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentAppTableHandle<'ctx> { + type DeleteCallbackId = PaymentAppDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentAppDeleteCallbackId { + PaymentAppDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentAppDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentAppUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentAppTableHandle<'ctx> { + type UpdateCallbackId = PaymentAppUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentAppUpdateCallbackId { + PaymentAppUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentAppUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentAppTableHandle<'ctx> { + type UpdateCallbackId = PaymentAppUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentAppUpdateCallbackId { + PaymentAppUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentAppUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `app_id` unique index on the table `payment_app`, +/// which allows point queries on the field of the same name +/// via the [`PaymentAppAppIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_app().app_id().find(...)`. +pub struct PaymentAppAppIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentAppTableHandle<'ctx> { + /// Get a handle on the `app_id` unique index on the table `payment_app`. + pub fn app_id(&self) -> PaymentAppAppIdUnique<'ctx> { + PaymentAppAppIdUnique { + imp: self.imp.get_unique_constraint::("app_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentAppAppIdUnique<'ctx> { + /// Find the subscribed row whose `app_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_app"); + _table.add_unique_constraint::("app_id", |row| &row.app_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentApp`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_appQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentApp`. + fn payment_app(&self) -> __sdk::__query_builder::Table; +} + +impl payment_appQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_app(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_app") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs new file mode 100644 index 000000000..f34b81a07 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_type.rs @@ -0,0 +1,69 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentApp { + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentApp { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentApp`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentAppCols { + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub name: __sdk::__query_builder::Col, + pub callback_url: __sdk::__query_builder::Col>, + pub enabled: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentApp { + type Cols = PaymentAppCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentAppCols { + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + name: __sdk::__query_builder::Col::new(table_name, "name"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + enabled: __sdk::__query_builder::Col::new(table_name, "enabled"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentApp`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentAppIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentApp { + type IxCols = PaymentAppIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentAppIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentApp {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs new file mode 100644 index 000000000..f83e5f760 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_app_update_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentAppUpdateInput { + pub app_id: String, + pub owner_user_id: String, + pub enabled: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentAppUpdateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs new file mode 100644 index 000000000..66b87f70e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_checkout_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderCheckoutGetInput { + pub checkout_token: String, +} + +impl __sdk::InModule for PaymentOrderCheckoutGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs new file mode 100644 index 000000000..a9952746d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_create_input_type.rs @@ -0,0 +1,31 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderCreateInput { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub checkout_token: String, + pub now_micros: i64, + pub expires_at_micros: i64, + pub callback_url: Option, + pub callback_signing_key_hash: Option, +} + +impl __sdk::InModule for PaymentOrderCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs new file mode 100644 index 000000000..203c66ccb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderGetInput { + pub order_id: String, + pub owner_user_id: Option, +} + +impl __sdk::InModule for PaymentOrderGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs new file mode 100644 index 000000000..2be50fede --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_list_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderListInput { + pub app_id: Option, + pub owner_user_id: String, + pub limit: u32, +} + +impl __sdk::InModule for PaymentOrderListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs new file mode 100644 index 000000000..837ac27f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_mark_paid_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderMarkPaidInput { + pub order_id: String, + pub provider_trade_no: String, + pub amount_cents: u64, + pub paid_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderMarkPaidInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs new file mode 100644 index 000000000..22c3f85af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_snapshot_type::PaymentOrderSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProcedureResult { + pub ok: bool, + pub order: Option, + pub orders: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentOrderProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs new file mode 100644 index 000000000..e2b0002bb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_attempt_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProviderAttemptInput { + pub order_id: String, + pub attempted_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderProviderAttemptInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs new file mode 100644 index 000000000..b02d63297 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_provider_code_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderProviderCodeInput { + pub order_id: String, + pub provider_code_url: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderProviderCodeInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs new file mode 100644 index 000000000..f2e351627 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_snapshot_type.rs @@ -0,0 +1,38 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrderSnapshot { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at_micros: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at_micros: i64, + pub expires_at_micros: i64, + pub paid_at_micros: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentOrderSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs new file mode 100644 index 000000000..046f3370c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_status_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PaymentOrderStatus { + Pending, + + Paying, + + Paid, + + Expired, + + Closed, + + Refunded, +} + +impl __sdk::InModule for PaymentOrderStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs new file mode 100644 index 000000000..58f65872a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_table.rs @@ -0,0 +1,328 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_order_type::PaymentOrder; +use super::payment_provider_type::PaymentProvider; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_order`. +/// +/// Obtain a handle from the [`PaymentOrderTableAccess::payment_order`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_order()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().on_insert(...)`. +pub struct PaymentOrderTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_order`. +pub struct PaymentOrderTableAccessor; + +impl __sdk::TableAccessor for PaymentOrderTableAccessor { + type Row = PaymentOrder; + type Handle<'db> = PaymentOrderTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_order() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_order`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentOrderTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentOrderTableHandle`], which mediates access to the table `payment_order`. + fn payment_order(&self) -> PaymentOrderTableHandle<'_>; +} + +impl PaymentOrderTableAccess for super::RemoteTables { + fn payment_order(&self) -> PaymentOrderTableHandle<'_> { + PaymentOrderTableHandle { + imp: self.imp.get_table::("payment_order"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentOrderInsertCallbackId(__sdk::CallbackId); +pub struct PaymentOrderDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentOrderTableHandle<'ctx> { + type Row = PaymentOrder; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentOrderTableHandle<'ctx> { + type Row = PaymentOrder; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentOrderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderInsertCallbackId { + PaymentOrderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentOrderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentOrderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderDeleteCallbackId { + PaymentOrderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentOrderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentOrderTableHandle<'ctx> { + type InsertCallbackId = PaymentOrderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderInsertCallbackId { + PaymentOrderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentOrderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentOrderTableHandle<'ctx> { + type DeleteCallbackId = PaymentOrderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentOrderDeleteCallbackId { + PaymentOrderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentOrderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentOrderUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentOrderTableHandle<'ctx> { + type UpdateCallbackId = PaymentOrderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentOrderUpdateCallbackId { + PaymentOrderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentOrderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentOrderTableHandle<'ctx> { + type UpdateCallbackId = PaymentOrderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentOrderUpdateCallbackId { + PaymentOrderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentOrderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `order_id` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderOrderIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().order_id().find(...)`. +pub struct PaymentOrderOrderIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `order_id` unique index on the table `payment_order`. + pub fn order_id(&self) -> PaymentOrderOrderIdUnique<'ctx> { + PaymentOrderOrderIdUnique { + imp: self.imp.get_unique_constraint::("order_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderOrderIdUnique<'ctx> { + /// Find the subscribed row whose `order_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `idempotency_lookup` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderIdempotencyLookupUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().idempotency_lookup().find(...)`. +pub struct PaymentOrderIdempotencyLookupUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `idempotency_lookup` unique index on the table `payment_order`. + pub fn idempotency_lookup(&self) -> PaymentOrderIdempotencyLookupUnique<'ctx> { + PaymentOrderIdempotencyLookupUnique { + imp: self + .imp + .get_unique_constraint::("idempotency_lookup"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderIdempotencyLookupUnique<'ctx> { + /// Find the subscribed row whose `idempotency_lookup` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `merchant_order_lookup` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderMerchantOrderLookupUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().merchant_order_lookup().find(...)`. +pub struct PaymentOrderMerchantOrderLookupUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `merchant_order_lookup` unique index on the table `payment_order`. + pub fn merchant_order_lookup(&self) -> PaymentOrderMerchantOrderLookupUnique<'ctx> { + PaymentOrderMerchantOrderLookupUnique { + imp: self + .imp + .get_unique_constraint::("merchant_order_lookup"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderMerchantOrderLookupUnique<'ctx> { + /// Find the subscribed row whose `merchant_order_lookup` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `checkout_token` unique index on the table `payment_order`, +/// which allows point queries on the field of the same name +/// via the [`PaymentOrderCheckoutTokenUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_order().checkout_token().find(...)`. +pub struct PaymentOrderCheckoutTokenUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentOrderTableHandle<'ctx> { + /// Get a handle on the `checkout_token` unique index on the table `payment_order`. + pub fn checkout_token(&self) -> PaymentOrderCheckoutTokenUnique<'ctx> { + PaymentOrderCheckoutTokenUnique { + imp: self.imp.get_unique_constraint::("checkout_token"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentOrderCheckoutTokenUnique<'ctx> { + /// Find the subscribed row whose `checkout_token` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("payment_order"); + _table.add_unique_constraint::("order_id", |row| &row.order_id); + _table.add_unique_constraint::("idempotency_lookup", |row| &row.idempotency_lookup); + _table + .add_unique_constraint::("merchant_order_lookup", |row| &row.merchant_order_lookup); + _table.add_unique_constraint::("checkout_token", |row| &row.checkout_token); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentOrder`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_orderQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentOrder`. + fn payment_order(&self) -> __sdk::__query_builder::Table; +} + +impl payment_orderQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_order(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_order") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs new file mode 100644 index 000000000..abb46c6d5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_order_type.rs @@ -0,0 +1,142 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_status_type::PaymentOrderStatus; +use super::payment_provider_type::PaymentProvider; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentOrder { + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + pub idempotency_lookup: String, + pub merchant_order_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at: __sdk::Timestamp, + pub expires_at: __sdk::Timestamp, + pub paid_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + pub provider_attempted_at: Option<__sdk::Timestamp>, +} + +impl __sdk::InModule for PaymentOrder { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentOrder`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentOrderCols { + pub order_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub merchant_order_id: __sdk::__query_builder::Col, + pub idempotency_lookup: __sdk::__query_builder::Col, + pub merchant_order_lookup: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub items_json: __sdk::__query_builder::Col, + pub amount_cents: __sdk::__query_builder::Col, + pub currency: __sdk::__query_builder::Col, + pub provider: __sdk::__query_builder::Col, + pub provider_trade_no: __sdk::__query_builder::Col>, + pub checkout_token: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub expires_at: __sdk::__query_builder::Col, + pub paid_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, + pub provider_code_url: __sdk::__query_builder::Col>, + pub callback_url: __sdk::__query_builder::Col>, + pub callback_signing_key_hash: __sdk::__query_builder::Col>, + pub provider_attempted_at: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PaymentOrder { + type Cols = PaymentOrderCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentOrderCols { + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + merchant_order_id: __sdk::__query_builder::Col::new(table_name, "merchant_order_id"), + idempotency_lookup: __sdk::__query_builder::Col::new(table_name, "idempotency_lookup"), + merchant_order_lookup: __sdk::__query_builder::Col::new( + table_name, + "merchant_order_lookup", + ), + title: __sdk::__query_builder::Col::new(table_name, "title"), + items_json: __sdk::__query_builder::Col::new(table_name, "items_json"), + amount_cents: __sdk::__query_builder::Col::new(table_name, "amount_cents"), + currency: __sdk::__query_builder::Col::new(table_name, "currency"), + provider: __sdk::__query_builder::Col::new(table_name, "provider"), + provider_trade_no: __sdk::__query_builder::Col::new(table_name, "provider_trade_no"), + checkout_token: __sdk::__query_builder::Col::new(table_name, "checkout_token"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), + paid_at: __sdk::__query_builder::Col::new(table_name, "paid_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + provider_code_url: __sdk::__query_builder::Col::new(table_name, "provider_code_url"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + callback_signing_key_hash: __sdk::__query_builder::Col::new( + table_name, + "callback_signing_key_hash", + ), + provider_attempted_at: __sdk::__query_builder::Col::new( + table_name, + "provider_attempted_at", + ), + } + } +} + +/// Indexed column accessor struct for the table `PaymentOrder`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentOrderIxCols { + pub app_id: __sdk::__query_builder::IxCol, + pub checkout_token: __sdk::__query_builder::IxCol, + pub idempotency_lookup: __sdk::__query_builder::IxCol, + pub merchant_order_lookup: __sdk::__query_builder::IxCol, + pub order_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub status: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentOrder { + type IxCols = PaymentOrderIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentOrderIxCols { + app_id: __sdk::__query_builder::IxCol::new(table_name, "app_id"), + checkout_token: __sdk::__query_builder::IxCol::new(table_name, "checkout_token"), + idempotency_lookup: __sdk::__query_builder::IxCol::new( + table_name, + "idempotency_lookup", + ), + merchant_order_lookup: __sdk::__query_builder::IxCol::new( + table_name, + "merchant_order_lookup", + ), + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + status: __sdk::__query_builder::IxCol::new(table_name, "status"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentOrder {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs new file mode 100644 index 000000000..c006a51ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_provider_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum PaymentProvider { + WechatNative, +} + +impl __sdk::InModule for PaymentProvider { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs new file mode 100644 index 000000000..3fdb121b4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_claim_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookClaimInput { + pub worker_id: String, + pub limit: u32, + pub now_micros: i64, + pub lease_expires_at_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs new file mode 100644 index 000000000..ecbdd90dd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_complete_input_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookCompleteInput { + pub delivery_id: String, + pub worker_id: String, + pub success: bool, + pub error_message: Option, + pub now_micros: i64, + pub next_available_at_micros: i64, + pub attempt_count: u32, +} + +impl __sdk::InModule for PaymentWebhookCompleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs new file mode 100644 index 000000000..22eca80db --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_snapshot_type.rs @@ -0,0 +1,29 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookDeliverySnapshot { + pub delivery_id: String, + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub callback_url: String, + pub payload_json: String, + pub signature: String, + pub status: String, + pub attempt_count: u32, + pub available_at_micros: i64, + pub lease_worker_id: Option, + pub lease_expires_at_micros: Option, + pub last_error_message: Option, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookDeliverySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs new file mode 100644 index 000000000..8cc76e053 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_table.rs @@ -0,0 +1,231 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::payment_webhook_delivery_type::PaymentWebhookDelivery; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `payment_webhook_delivery`. +/// +/// Obtain a handle from the [`PaymentWebhookDeliveryTableAccess::payment_webhook_delivery`] method on [`super::RemoteTables`], +/// like `ctx.db.payment_webhook_delivery()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_webhook_delivery().on_insert(...)`. +pub struct PaymentWebhookDeliveryTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `payment_webhook_delivery`. +pub struct PaymentWebhookDeliveryTableAccessor; + +impl __sdk::TableAccessor for PaymentWebhookDeliveryTableAccessor { + type Row = PaymentWebhookDelivery; + type Handle<'db> = PaymentWebhookDeliveryTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.payment_webhook_delivery() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `payment_webhook_delivery`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PaymentWebhookDeliveryTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PaymentWebhookDeliveryTableHandle`], which mediates access to the table `payment_webhook_delivery`. + fn payment_webhook_delivery(&self) -> PaymentWebhookDeliveryTableHandle<'_>; +} + +impl PaymentWebhookDeliveryTableAccess for super::RemoteTables { + fn payment_webhook_delivery(&self) -> PaymentWebhookDeliveryTableHandle<'_> { + PaymentWebhookDeliveryTableHandle { + imp: self + .imp + .get_table::("payment_webhook_delivery"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PaymentWebhookDeliveryInsertCallbackId(__sdk::CallbackId); +pub struct PaymentWebhookDeliveryDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for PaymentWebhookDeliveryTableHandle<'ctx> { + type Row = PaymentWebhookDelivery; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for PaymentWebhookDeliveryTableHandle<'ctx> { + type Row = PaymentWebhookDelivery; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PaymentWebhookDeliveryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryInsertCallbackId { + PaymentWebhookDeliveryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentWebhookDeliveryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PaymentWebhookDeliveryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryDeleteCallbackId { + PaymentWebhookDeliveryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentWebhookDeliveryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for PaymentWebhookDeliveryTableHandle<'ctx> { + type InsertCallbackId = PaymentWebhookDeliveryInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryInsertCallbackId { + PaymentWebhookDeliveryInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PaymentWebhookDeliveryInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for PaymentWebhookDeliveryTableHandle<'ctx> { + type DeleteCallbackId = PaymentWebhookDeliveryDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryDeleteCallbackId { + PaymentWebhookDeliveryDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PaymentWebhookDeliveryDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PaymentWebhookDeliveryUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PaymentWebhookDeliveryTableHandle<'ctx> { + type UpdateCallbackId = PaymentWebhookDeliveryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryUpdateCallbackId { + PaymentWebhookDeliveryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentWebhookDeliveryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for PaymentWebhookDeliveryTableHandle<'ctx> { + type UpdateCallbackId = PaymentWebhookDeliveryUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PaymentWebhookDeliveryUpdateCallbackId { + PaymentWebhookDeliveryUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PaymentWebhookDeliveryUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `delivery_id` unique index on the table `payment_webhook_delivery`, +/// which allows point queries on the field of the same name +/// via the [`PaymentWebhookDeliveryDeliveryIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.payment_webhook_delivery().delivery_id().find(...)`. +pub struct PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PaymentWebhookDeliveryTableHandle<'ctx> { + /// Get a handle on the `delivery_id` unique index on the table `payment_webhook_delivery`. + pub fn delivery_id(&self) -> PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + PaymentWebhookDeliveryDeliveryIdUnique { + imp: self.imp.get_unique_constraint::("delivery_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PaymentWebhookDeliveryDeliveryIdUnique<'ctx> { + /// Find the subscribed row whose `delivery_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("payment_webhook_delivery"); + _table.add_unique_constraint::("delivery_id", |row| &row.delivery_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PaymentWebhookDelivery`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait payment_webhook_deliveryQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PaymentWebhookDelivery`. + fn payment_webhook_delivery(&self) -> __sdk::__query_builder::Table; +} + +impl payment_webhook_deliveryQueryTableAccess for __sdk::QueryTableAccessor { + fn payment_webhook_delivery(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("payment_webhook_delivery") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs new file mode 100644 index 000000000..be33d532c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_delivery_type.rs @@ -0,0 +1,96 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookDelivery { + pub delivery_id: String, + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub callback_url: String, + pub payload_json: String, + pub signature: String, + pub status: String, + pub attempt_count: u32, + pub available_at: __sdk::Timestamp, + pub lease_worker_id: Option, + pub lease_expires_at: Option<__sdk::Timestamp>, + pub last_error_message: Option, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for PaymentWebhookDelivery { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PaymentWebhookDelivery`. +/// +/// Provides typed access to columns for query building. +pub struct PaymentWebhookDeliveryCols { + pub delivery_id: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub app_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub callback_url: __sdk::__query_builder::Col, + pub payload_json: __sdk::__query_builder::Col, + pub signature: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub attempt_count: __sdk::__query_builder::Col, + pub available_at: __sdk::__query_builder::Col, + pub lease_worker_id: __sdk::__query_builder::Col>, + pub lease_expires_at: + __sdk::__query_builder::Col>, + pub last_error_message: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PaymentWebhookDelivery { + type Cols = PaymentWebhookDeliveryCols; + fn cols(table_name: &'static str) -> Self::Cols { + PaymentWebhookDeliveryCols { + delivery_id: __sdk::__query_builder::Col::new(table_name, "delivery_id"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + app_id: __sdk::__query_builder::Col::new(table_name, "app_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + callback_url: __sdk::__query_builder::Col::new(table_name, "callback_url"), + payload_json: __sdk::__query_builder::Col::new(table_name, "payload_json"), + signature: __sdk::__query_builder::Col::new(table_name, "signature"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + attempt_count: __sdk::__query_builder::Col::new(table_name, "attempt_count"), + available_at: __sdk::__query_builder::Col::new(table_name, "available_at"), + lease_worker_id: __sdk::__query_builder::Col::new(table_name, "lease_worker_id"), + lease_expires_at: __sdk::__query_builder::Col::new(table_name, "lease_expires_at"), + last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `PaymentWebhookDelivery`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PaymentWebhookDeliveryIxCols { + pub delivery_id: __sdk::__query_builder::IxCol, + pub order_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PaymentWebhookDelivery { + type IxCols = PaymentWebhookDeliveryIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PaymentWebhookDeliveryIxCols { + delivery_id: __sdk::__query_builder::IxCol::new(table_name, "delivery_id"), + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PaymentWebhookDelivery {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs new file mode 100644 index 000000000..d44d70835 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_enqueue_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookEnqueueInput { + pub order_id: String, + pub payload_json: String, + pub signature: String, + pub now_micros: i64, +} + +impl __sdk::InModule for PaymentWebhookEnqueueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs new file mode 100644 index 000000000..0ffbd510e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/payment_webhook_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_webhook_delivery_snapshot_type::PaymentWebhookDeliverySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PaymentWebhookProcedureResult { + pub ok: bool, + pub delivery: Option, + pub deliveries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for PaymentWebhookProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs new file mode 100644 index 000000000..659adfe4c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/revoke_payment_api_key_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_api_key_revoke_input_type::PaymentApiKeyRevokeInput; +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RevokePaymentApiKeyAndReturnArgs { + pub input: PaymentApiKeyRevokeInput, +} + +impl __sdk::InModule for RevokePaymentApiKeyAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `revoke_payment_api_key_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait revoke_payment_api_key_and_return { + fn revoke_payment_api_key_and_return(&self, input: PaymentApiKeyRevokeInput) { + self.revoke_payment_api_key_and_return_then(input, |_, _| {}); + } + + fn revoke_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl revoke_payment_api_key_and_return for super::RemoteProcedures { + fn revoke_payment_api_key_and_return_then( + &self, + input: PaymentApiKeyRevokeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "revoke_payment_api_key_and_return", + RevokePaymentApiKeyAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs new file mode 100644 index 000000000..c2ee27aa6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/set_payment_order_provider_code_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_order_procedure_result_type::PaymentOrderProcedureResult; +use super::payment_order_provider_code_input_type::PaymentOrderProviderCodeInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SetPaymentOrderProviderCodeAndReturnArgs { + pub input: PaymentOrderProviderCodeInput, +} + +impl __sdk::InModule for SetPaymentOrderProviderCodeAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `set_payment_order_provider_code_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait set_payment_order_provider_code_and_return { + fn set_payment_order_provider_code_and_return(&self, input: PaymentOrderProviderCodeInput) { + self.set_payment_order_provider_code_and_return_then(input, |_, _| {}); + } + + fn set_payment_order_provider_code_and_return_then( + &self, + input: PaymentOrderProviderCodeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl set_payment_order_provider_code_and_return for super::RemoteProcedures { + fn set_payment_order_provider_code_and_return_then( + &self, + input: PaymentOrderProviderCodeInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentOrderProcedureResult>( + "set_payment_order_provider_code_and_return", + SetPaymentOrderProviderCodeAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs new file mode 100644 index 000000000..c87253b43 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/update_payment_app_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::payment_app_procedure_result_type::PaymentAppProcedureResult; +use super::payment_app_update_input_type::PaymentAppUpdateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UpdatePaymentAppAndReturnArgs { + pub input: PaymentAppUpdateInput, +} + +impl __sdk::InModule for UpdatePaymentAppAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `update_payment_app_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait update_payment_app_and_return { + fn update_payment_app_and_return(&self, input: PaymentAppUpdateInput) { + self.update_payment_app_and_return_then(input, |_, _| {}); + } + + fn update_payment_app_and_return_then( + &self, + input: PaymentAppUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl update_payment_app_and_return for super::RemoteProcedures { + fn update_payment_app_and_return_then( + &self, + input: PaymentAppUpdateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PaymentAppProcedureResult>( + "update_payment_app_and_return", + UpdatePaymentAppAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/active.rs b/server-rs/crates/spacetime-module/src/active.rs index de0a60919..12d2789e2 100644 --- a/server-rs/crates/spacetime-module/src/active.rs +++ b/server-rs/crates/spacetime-module/src/active.rs @@ -29,6 +29,7 @@ mod external_generation; mod game_distribution; #[path = "llm_router_account.rs"] mod llm_router_account_storage; +mod payment_storage; pub use agc_models::*; mod migration; mod runtime; @@ -46,6 +47,7 @@ pub use external_generation::*; pub use game_distribution::*; pub use llm_router_account_storage::*; pub use migration::*; +pub use payment_storage::*; pub use runtime::*; // Host-side unit tests need to link the module crate as a normal test binary. diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index c2de622d6..72710eb96 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -972,6 +972,12 @@ pub struct EditorAssetLibraryGetInput { pub now_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAssetFolderGetInput { + pub folder_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetFolderCreateInput { pub folder_id: String, @@ -1872,6 +1878,17 @@ pub fn find_editor_asset_group_source_and_return( } } +#[spacetimedb::procedure] +pub fn get_editor_asset_folder_and_return( + ctx: &mut ProcedureContext, + input: EditorAssetFolderGetInput, +) -> EditorAssetFolderProcedureResult { + match ctx.try_with_tx(|tx| get_editor_asset_folder(tx, input.clone())) { + Ok(folder) => editor_asset_folder_ok(Some(folder), None), + Err(message) => editor_asset_folder_error(message), + } +} + #[spacetimedb::procedure] pub fn create_editor_asset_folder_and_return( ctx: &mut ProcedureContext, @@ -5777,6 +5794,17 @@ fn create_editor_asset_folder( .ok_or_else(|| "素材文件夹创建失败".to_string()) } +fn get_editor_asset_folder( + ctx: &ReducerContext, + input: EditorAssetFolderGetInput, +) -> Result { + let folder_id = normalize_required(&input.folder_id, "editor_asset_folder.folder_id")?; + let owner_user_id = + normalize_required(&input.owner_user_id, "editor_asset_folder.owner_user_id")?; + require_owned_asset_folder(ctx, folder_id.as_str(), owner_user_id.as_str()) + .map(asset_folder_snapshot_from_row) +} + fn update_editor_asset_folder( ctx: &ReducerContext, input: EditorAssetFolderUpdateInput, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 5018f86d8..e494e95e3 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -205,6 +205,10 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, external_api_key, + payment_app, + payment_api_key, + payment_order, + payment_webhook_delivery, // 游戏分发:游戏身份、不可变发行版本和幂等收据都属于业务事实, // 随迁移导出/导入;私有 ZIP 与展开内容保存在对象存储,不进入这两张表。 // 资料随版本冻结:版本表末尾追加 metadata_json,游戏表末尾追加 diff --git a/server-rs/crates/spacetime-module/src/payment_storage.rs b/server-rs/crates/spacetime-module/src/payment_storage.rs new file mode 100644 index 000000000..375c1c641 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/payment_storage.rs @@ -0,0 +1,1199 @@ +use crate::*; + +#[spacetimedb::table( + accessor = payment_app, + index(accessor = by_payment_app_owner_user_id, btree(columns = [owner_user_id])) +)] +#[derive(Clone)] +pub struct PaymentApp { + #[primary_key] + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub callback_url: Option, + pub enabled: bool, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = payment_api_key, + index(accessor = by_payment_api_key_app_id, btree(columns = [app_id])), + index(accessor = by_payment_api_key_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct PaymentApiKey { + #[primary_key] + pub key_id: String, + pub app_id: String, + pub owner_user_id: String, + pub name: String, + pub key_prefix: String, + #[unique] + pub key_hash: String, + pub scopes_json: String, + pub created_at: Timestamp, + pub last_used_at: Option, + pub revoked_at: Option, + pub updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = payment_order, + index(accessor = by_payment_order_app_id, btree(columns = [app_id])), + index(accessor = by_payment_order_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_payment_order_status, btree(columns = [status])) +)] +#[derive(Clone)] +pub struct PaymentOrder { + #[primary_key] + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub merchant_order_id: String, + #[unique] + pub idempotency_lookup: String, + #[unique] + pub merchant_order_lookup: String, + pub title: String, + pub items_json: String, + pub amount_cents: u64, + pub currency: String, + pub provider: PaymentProvider, + pub provider_trade_no: Option, + #[unique] + pub checkout_token: String, + pub status: PaymentOrderStatus, + pub created_at: Timestamp, + pub expires_at: Timestamp, + pub paid_at: Option, + pub updated_at: Timestamp, + pub provider_code_url: Option, + pub callback_url: Option, + pub callback_signing_key_hash: Option, + #[default(None::)] + pub provider_attempted_at: Option, +} + +#[spacetimedb::table( + accessor = payment_webhook_delivery, + index(accessor = by_payment_webhook_delivery_status_available, btree(columns = [status, available_at])), + index(accessor = by_payment_webhook_delivery_owner_user_id, btree(columns = [owner_user_id])), + index(accessor = by_payment_webhook_delivery_order_id, btree(columns = [order_id])) +)] +#[derive(Clone)] +pub struct PaymentWebhookDelivery { + #[primary_key] + pub delivery_id: String, + pub order_id: String, + pub app_id: String, + pub owner_user_id: String, + pub callback_url: String, + pub payload_json: String, + pub signature: String, + pub status: String, + pub attempt_count: u32, + pub available_at: Timestamp, + pub lease_worker_id: Option, + pub lease_expires_at: Option, + pub last_error_message: Option, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +const PAYMENT_WEBHOOK_STATUS_PENDING: &str = "pending"; +const PAYMENT_WEBHOOK_STATUS_PROCESSING: &str = "processing"; +const PAYMENT_WEBHOOK_STATUS_DELIVERED: &str = "delivered"; +const PAYMENT_WEBHOOK_STATUS_FAILED: &str = "failed"; + +#[spacetimedb::procedure] +pub fn create_payment_app_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppCreateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_app(tx, input.clone()) + }) { + Ok(app) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_apps_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppListInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_apps(tx, input.clone()) + }) { + Ok(apps) => PaymentAppProcedureResult { + ok: true, + app: None, + key: None, + apps, + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn update_payment_app_and_return( + ctx: &mut ProcedureContext, + input: PaymentAppUpdateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + update_payment_app(tx, input.clone()) + }) { + Ok(app) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_api_keys_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyListInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_api_keys(tx, input.clone()) + }) { + Ok(keys) => PaymentAppProcedureResult { + ok: true, + app: None, + key: None, + apps: Vec::new(), + keys, + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn create_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyCreateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_api_key(tx, input.clone()) + }) { + Ok(key) => PaymentAppProcedureResult { + ok: true, + app: None, + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn revoke_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyRevokeInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + revoke_payment_api_key(tx, input.clone()) + }) { + Ok(key) => PaymentAppProcedureResult { + ok: true, + app: None, + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn authenticate_payment_api_key_and_return( + ctx: &mut ProcedureContext, + input: PaymentApiKeyAuthenticateInput, +) -> PaymentAppProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + authenticate_payment_api_key(tx, input.clone()) + }) { + Ok((app, key)) => PaymentAppProcedureResult { + ok: true, + app: Some(app), + key: Some(key), + apps: Vec::new(), + keys: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_app_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn create_payment_order_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderCreateInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + create_payment_order(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn get_payment_order_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderGetInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + get_payment_order(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn get_payment_order_by_checkout_token_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderCheckoutGetInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + get_payment_order_by_checkout_token(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn list_payment_orders_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderListInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + list_payment_orders(tx, input.clone()) + }) { + Ok(orders) => PaymentOrderProcedureResult { + ok: true, + order: None, + orders, + error_message: None, + }, + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn mark_payment_order_paid_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderMarkPaidInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + mark_payment_order_paid(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn set_payment_order_provider_code_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderProviderCodeInput, +) -> PaymentOrderProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + set_payment_order_provider_code(tx, input.clone()) + }) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn mark_payment_order_provider_attempted_and_return( + ctx: &mut ProcedureContext, + input: PaymentOrderProviderAttemptInput, +) -> PaymentOrderProcedureResult { + match ctx.try_with_tx(|tx| mark_payment_order_provider_attempted(tx, input.clone())) { + Ok(order) => payment_order_single_ok(order), + Err(error_message) => payment_order_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn enqueue_payment_webhook_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookEnqueueInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + enqueue_payment_webhook(tx, input.clone()) + }) { + Ok(delivery) => PaymentWebhookProcedureResult { + ok: true, + delivery, + deliveries: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn claim_payment_webhooks_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookClaimInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + claim_payment_webhooks(tx, input.clone()) + }) { + Ok(deliveries) => PaymentWebhookProcedureResult { + ok: true, + delivery: None, + deliveries, + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +#[spacetimedb::procedure] +pub fn complete_payment_webhook_and_return( + ctx: &mut ProcedureContext, + input: PaymentWebhookCompleteInput, +) -> PaymentWebhookProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::require_editor_generation_runtime_service_identity(tx, caller)?; + complete_payment_webhook(tx, input.clone()) + }) { + Ok(delivery) => PaymentWebhookProcedureResult { + ok: true, + delivery: Some(delivery), + deliveries: Vec::new(), + error_message: None, + }, + Err(error_message) => payment_webhook_error(error_message), + } +} + +fn create_payment_app( + ctx: &ReducerContext, + input: PaymentAppCreateInput, +) -> Result { + let input = build_payment_app_create_input( + input.app_id, + input.owner_user_id, + input.name, + input.callback_url, + input.now_micros, + )?; + if ctx.db.payment_app().app_id().find(&input.app_id).is_some() { + return Err("支付应用 ID 已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db.payment_app().insert(PaymentApp { + app_id: input.app_id.clone(), + owner_user_id: input.owner_user_id, + name: input.name, + callback_url: input.callback_url, + enabled: true, + created_at: now, + updated_at: now, + }); + ctx.db + .payment_app() + .app_id() + .find(&input.app_id) + .map(|row| payment_app_snapshot(&row)) + .ok_or_else(|| "支付应用创建后读取失败".to_string()) +} + +fn list_payment_apps( + ctx: &ReducerContext, + input: PaymentAppListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_app.owner_user_id")?; + let mut apps = ctx + .db + .payment_app() + .by_payment_app_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_app_snapshot(&row)) + .collect::>(); + apps.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + Ok(apps) +} + +fn update_payment_app( + ctx: &ReducerContext, + input: PaymentAppUpdateInput, +) -> Result { + let app_id = required(input.app_id, "payment_app.app_id")?; + let owner_user_id = required(input.owner_user_id, "payment_app.owner_user_id")?; + let row = ctx + .db + .payment_app() + .app_id() + .find(&app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问支付应用".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + ctx.db.payment_app().app_id().delete(&app_id); + ctx.db.payment_app().insert(PaymentApp { + enabled: input.enabled, + updated_at: now, + ..row + }); + ctx.db + .payment_app() + .app_id() + .find(&app_id) + .map(|row| payment_app_snapshot(&row)) + .ok_or_else(|| "支付应用更新后读取失败".to_string()) +} + +fn list_payment_api_keys( + ctx: &ReducerContext, + input: PaymentApiKeyListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_api_key.owner_user_id")?; + let mut keys = ctx + .db + .payment_api_key() + .by_payment_api_key_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_api_key_snapshot(&row)) + .collect::>(); + keys.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + Ok(keys) +} + +fn create_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyCreateInput, +) -> Result { + let input = build_payment_api_key_create_input( + input.key_id, + input.app_id, + input.owner_user_id, + input.name, + input.key_prefix, + input.key_hash, + input.scopes_json, + input.now_micros, + )?; + let app = ctx + .db + .payment_app() + .app_id() + .find(&input.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if app.owner_user_id != input.owner_user_id || !app.enabled { + return Err("支付应用不可用".to_string()); + } + if ctx + .db + .payment_api_key() + .key_hash() + .find(&input.key_hash) + .is_some() + { + return Err("支付 API Key 摘要已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db.payment_api_key().insert(PaymentApiKey { + key_id: input.key_id.clone(), + app_id: input.app_id, + owner_user_id: input.owner_user_id, + name: input.name, + key_prefix: input.key_prefix, + key_hash: input.key_hash, + scopes_json: input.scopes_json, + created_at: now, + last_used_at: None, + revoked_at: None, + updated_at: now, + }); + ctx.db + .payment_api_key() + .key_id() + .find(&input.key_id) + .map(|row| payment_api_key_snapshot(&row)) + .ok_or_else(|| "支付 API Key 创建后读取失败".to_string()) +} + +fn revoke_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyRevokeInput, +) -> Result { + let key_id = required(input.key_id, "payment_api_key.key_id")?; + let owner_user_id = required(input.owner_user_id, "payment_api_key.owner_user_id")?; + let row = ctx + .db + .payment_api_key() + .key_id() + .find(&key_id) + .ok_or_else(|| "支付 API Key 不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权访问支付 API Key".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.revoked_at_micros); + ctx.db.payment_api_key().key_id().delete(&key_id); + ctx.db.payment_api_key().insert(PaymentApiKey { + revoked_at: row.revoked_at.or(Some(now)), + updated_at: now, + ..row + }); + ctx.db + .payment_api_key() + .key_id() + .find(&key_id) + .map(|row| payment_api_key_snapshot(&row)) + .ok_or_else(|| "支付 API Key 撤销后读取失败".to_string()) +} + +fn authenticate_payment_api_key( + ctx: &ReducerContext, + input: PaymentApiKeyAuthenticateInput, +) -> Result<(PaymentAppSnapshot, PaymentApiKeySnapshot), String> { + let key_hash = required(input.key_hash, "payment_api_key.key_hash")?; + let key = ctx + .db + .payment_api_key() + .key_hash() + .find(&key_hash) + .ok_or_else(|| "支付 API Key 不存在或已失效".to_string())?; + if key.revoked_at.is_some() { + return Err("支付 API Key 不存在或已失效".to_string()); + } + let app = ctx + .db + .payment_app() + .app_id() + .find(&key.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if !app.enabled || app.owner_user_id != key.owner_user_id { + return Err("支付应用不可用".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.used_at_micros); + let key_id = key.key_id.clone(); + ctx.db.payment_api_key().key_id().delete(&key_id); + ctx.db.payment_api_key().insert(PaymentApiKey { + last_used_at: Some(now), + updated_at: now, + ..key + }); + let key = ctx + .db + .payment_api_key() + .key_id() + .find(&key_id) + .ok_or_else(|| "支付 API Key 更新失败".to_string())?; + Ok((payment_app_snapshot(&app), payment_api_key_snapshot(&key))) +} + +fn create_payment_order( + ctx: &ReducerContext, + input: PaymentOrderCreateInput, +) -> Result { + let input = build_payment_order_create_input( + input.order_id, + input.app_id, + input.owner_user_id, + input.merchant_order_id, + input.idempotency_lookup, + input.title, + input.items_json, + input.amount_cents, + input.currency, + input.provider, + input.checkout_token, + input.now_micros, + input.expires_at_micros, + input.callback_url.clone(), + input.callback_signing_key_hash.clone(), + )?; + let app = ctx + .db + .payment_app() + .app_id() + .find(&input.app_id) + .ok_or_else(|| "支付应用不存在".to_string())?; + if !app.enabled || app.owner_user_id != input.owner_user_id { + return Err("支付应用不可用".to_string()); + } + if let Some(existing) = ctx + .db + .payment_order() + .idempotency_lookup() + .find(&input.idempotency_lookup) + { + if existing.app_id != input.app_id + || existing.amount_cents != input.amount_cents + || existing.title != input.title + || existing.items_json != input.items_json + { + return Err("相同幂等键对应的订单字段不一致".to_string()); + } + return Ok(payment_order_snapshot(&existing)); + } + let merchant_order_lookup = format!("{}:{}", input.app_id, input.merchant_order_id); + if ctx + .db + .payment_order() + .merchant_order_lookup() + .find(&merchant_order_lookup) + .is_some() + { + return Err("接入方订单号已存在".to_string()); + } + if ctx + .db + .payment_order() + .checkout_token() + .find(&input.checkout_token) + .is_some() + { + return Err("收银台 token 已存在".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let expires_at = Timestamp::from_micros_since_unix_epoch(input.expires_at_micros); + ctx.db.payment_order().insert(PaymentOrder { + order_id: input.order_id.clone(), + app_id: input.app_id, + owner_user_id: input.owner_user_id, + merchant_order_id: input.merchant_order_id, + idempotency_lookup: input.idempotency_lookup, + merchant_order_lookup, + title: input.title, + items_json: input.items_json, + amount_cents: input.amount_cents, + currency: input.currency, + provider: input.provider, + provider_trade_no: None, + checkout_token: input.checkout_token, + status: PaymentOrderStatus::Pending, + created_at: now, + expires_at, + paid_at: None, + updated_at: now, + provider_code_url: None, + callback_url: input.callback_url, + callback_signing_key_hash: input.callback_signing_key_hash, + provider_attempted_at: None, + }); + ctx.db + .payment_order() + .order_id() + .find(&input.order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单创建后读取失败".to_string()) +} + +fn get_payment_order( + ctx: &ReducerContext, + input: PaymentOrderGetInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if let Some(owner_user_id) = input.owner_user_id { + if order.owner_user_id != required(owner_user_id, "payment_order.owner_user_id")? { + return Err("无权访问支付订单".to_string()); + } + } + Ok(payment_order_snapshot(&order)) +} + +fn get_payment_order_by_checkout_token( + ctx: &ReducerContext, + input: PaymentOrderCheckoutGetInput, +) -> Result { + let token = required(input.checkout_token, "payment_order.checkout_token")?; + ctx.db + .payment_order() + .checkout_token() + .find(&token) + .map(|order| payment_order_snapshot(&order)) + .ok_or_else(|| "支付订单不存在".to_string()) +} + +fn list_payment_orders( + ctx: &ReducerContext, + input: PaymentOrderListInput, +) -> Result, String> { + let owner_user_id = required(input.owner_user_id, "payment_order.owner_user_id")?; + let limit = input.limit.clamp(1, 100) as usize; + let mut orders = match input.app_id { + Some(app_id) => ctx + .db + .payment_order() + .by_payment_order_app_id() + .filter(&required(app_id, "payment_order.app_id")?) + .filter(|order| order.owner_user_id == owner_user_id) + .map(|row| payment_order_snapshot(&row)) + .collect::>(), + None => ctx + .db + .payment_order() + .by_payment_order_owner_user_id() + .filter(&owner_user_id) + .map(|row| payment_order_snapshot(&row)) + .collect::>(), + }; + orders.sort_by(|left, right| right.created_at_micros.cmp(&left.created_at_micros)); + orders.truncate(limit); + Ok(orders) +} + +fn mark_payment_order_paid( + ctx: &ReducerContext, + input: PaymentOrderMarkPaidInput, +) -> Result { + let input = build_payment_order_mark_paid_input( + input.order_id, + input.provider_trade_no, + input.amount_cents, + input.paid_at_micros, + )?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&input.order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.amount_cents != input.amount_cents { + return Err("支付金额与本地订单不一致".to_string()); + } + if order.status == PaymentOrderStatus::Paid { + if order.provider_trade_no.as_deref() != Some(input.provider_trade_no.as_str()) { + return Err("已支付订单的平台交易号不一致".to_string()); + } + return Ok(payment_order_snapshot(&order)); + } + if matches!( + order.status, + PaymentOrderStatus::Closed | PaymentOrderStatus::Refunded + ) { + return Err("当前支付订单状态不能确认支付".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.paid_at_micros); + ctx.db.payment_order().order_id().delete(&order.order_id); + ctx.db.payment_order().insert(PaymentOrder { + status: PaymentOrderStatus::Paid, + provider_trade_no: Some(input.provider_trade_no), + paid_at: Some(now), + updated_at: now, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&input.order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单确认后读取失败".to_string()) +} + +fn set_payment_order_provider_code( + ctx: &ReducerContext, + input: PaymentOrderProviderCodeInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let provider_code_url = required(input.provider_code_url, "payment_order.provider_code_url")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if matches!( + order.status, + PaymentOrderStatus::Paid | PaymentOrderStatus::Closed + ) { + return Ok(payment_order_snapshot(&order)); + } + let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + ctx.db.payment_order().order_id().delete(&order_id); + ctx.db.payment_order().insert(PaymentOrder { + provider_code_url: Some(provider_code_url), + updated_at, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单 provider 结果写入后读取失败".to_string()) +} + +fn mark_payment_order_provider_attempted( + ctx: &ReducerContext, + input: PaymentOrderProviderAttemptInput, +) -> Result { + let order_id = required(input.order_id, "payment_order.order_id")?; + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.provider_code_url.is_some() || order.status == PaymentOrderStatus::Paid { + return Ok(payment_order_snapshot(&order)); + } + if order.provider_attempted_at.is_some() { + return Err("PAYMENT_PROVIDER_PENDING".to_string()); + } + let attempted_at = Timestamp::from_micros_since_unix_epoch(input.attempted_at_micros); + ctx.db.payment_order().order_id().delete(&order_id); + ctx.db.payment_order().insert(PaymentOrder { + status: PaymentOrderStatus::Paying, + provider_attempted_at: Some(attempted_at), + updated_at: attempted_at, + ..order + }); + ctx.db + .payment_order() + .order_id() + .find(&order_id) + .map(|row| payment_order_snapshot(&row)) + .ok_or_else(|| "支付订单 provider claim 后读取失败".to_string()) +} + +fn enqueue_payment_webhook( + ctx: &ReducerContext, + input: PaymentWebhookEnqueueInput, +) -> Result, String> { + let order_id = required(input.order_id, "payment_webhook.order_id")?; + let payload_json = required(input.payload_json, "payment_webhook.payload_json")?; + let signature = required(input.signature, "payment_webhook.signature")?; + if payload_json.len() > 64 * 1024 { + return Err("payment_webhook.payload_json 超出大小限制".to_string()); + } + let order = ctx + .db + .payment_order() + .order_id() + .find(&order_id) + .ok_or_else(|| "支付订单不存在".to_string())?; + if order.status != PaymentOrderStatus::Paid { + return Err("只有已支付订单可以创建外部回调".to_string()); + } + let Some(callback_url) = order.callback_url.clone() else { + return Ok(None); + }; + let delivery_id = format!("payment-webhook:{order_id}"); + if let Some(existing) = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + { + if existing.payload_json != payload_json || existing.signature != signature { + return Err("相同支付订单的回调内容不一致".to_string()); + } + return Ok(Some(payment_webhook_snapshot(&existing))); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + ctx.db + .payment_webhook_delivery() + .insert(PaymentWebhookDelivery { + delivery_id: delivery_id.clone(), + order_id: order.order_id, + app_id: order.app_id, + owner_user_id: order.owner_user_id, + callback_url, + payload_json, + signature, + status: PAYMENT_WEBHOOK_STATUS_PENDING.to_string(), + attempt_count: 0, + available_at: now, + lease_worker_id: None, + lease_expires_at: None, + last_error_message: None, + created_at: now, + updated_at: now, + }); + ctx.db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + .map(|row| payment_webhook_snapshot(&row)) + .map(Some) + .ok_or_else(|| "支付回调写入后读取失败".to_string()) +} + +fn claim_payment_webhooks( + ctx: &ReducerContext, + input: PaymentWebhookClaimInput, +) -> Result, String> { + let worker_id = required(input.worker_id, "payment_webhook.worker_id")?; + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + let lease_expires_at = Timestamp::from_micros_since_unix_epoch(input.lease_expires_at_micros); + let limit = input.limit.clamp(1, 50) as usize; + let mut rows = ctx + .db + .payment_webhook_delivery() + .iter() + .filter(|row| { + (row.status == PAYMENT_WEBHOOK_STATUS_PENDING && row.available_at <= now) + || (row.status == PAYMENT_WEBHOOK_STATUS_PROCESSING + && row.lease_expires_at.is_some_and(|value| value <= now)) + }) + .collect::>(); + rows.sort_by(|left, right| left.available_at.cmp(&right.available_at)); + rows.truncate(limit); + let mut claimed = Vec::with_capacity(rows.len()); + for row in rows { + let delivery_id = row.delivery_id.clone(); + ctx.db + .payment_webhook_delivery() + .delivery_id() + .delete(&delivery_id); + ctx.db + .payment_webhook_delivery() + .insert(PaymentWebhookDelivery { + status: PAYMENT_WEBHOOK_STATUS_PROCESSING.to_string(), + lease_worker_id: Some(worker_id.clone()), + lease_expires_at: Some(lease_expires_at), + updated_at: now, + ..row + }); + if let Some(updated) = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + { + claimed.push(payment_webhook_snapshot(&updated)); + } + } + Ok(claimed) +} + +fn complete_payment_webhook( + ctx: &ReducerContext, + input: PaymentWebhookCompleteInput, +) -> Result { + let delivery_id = required(input.delivery_id, "payment_webhook.delivery_id")?; + let worker_id = required(input.worker_id, "payment_webhook.worker_id")?; + let mut row = ctx + .db + .payment_webhook_delivery() + .delivery_id() + .find(&delivery_id) + .ok_or_else(|| "支付回调投递不存在".to_string())?; + if row.status != PAYMENT_WEBHOOK_STATUS_PROCESSING + || row.lease_worker_id.as_deref() != Some(worker_id.as_str()) + { + return Err("支付回调投递租约不属于当前 worker".to_string()); + } + let now = Timestamp::from_micros_since_unix_epoch(input.now_micros); + row.lease_worker_id = None; + row.lease_expires_at = None; + row.updated_at = now; + if input.success { + row.status = PAYMENT_WEBHOOK_STATUS_DELIVERED.to_string(); + row.last_error_message = None; + } else { + row.attempt_count = row.attempt_count.saturating_add(1); + row.last_error_message = input + .error_message + .and_then(|value| normalize_optional_text(Some(value))); + if row.attempt_count >= PAYMENT_WEBHOOK_MAX_ATTEMPTS { + row.status = PAYMENT_WEBHOOK_STATUS_FAILED.to_string(); + } else { + row.status = PAYMENT_WEBHOOK_STATUS_PENDING.to_string(); + row.available_at = + Timestamp::from_micros_since_unix_epoch(input.next_available_at_micros); + } + } + ctx.db + .payment_webhook_delivery() + .delivery_id() + .delete(&delivery_id); + ctx.db.payment_webhook_delivery().insert(row.clone()); + Ok(payment_webhook_snapshot(&row)) +} + +fn payment_app_snapshot(row: &PaymentApp) -> PaymentAppSnapshot { + PaymentAppSnapshot { + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + name: row.name.clone(), + callback_url: row.callback_url.clone(), + enabled: row.enabled, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_api_key_snapshot(row: &PaymentApiKey) -> PaymentApiKeySnapshot { + PaymentApiKeySnapshot { + key_id: row.key_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + name: row.name.clone(), + key_prefix: row.key_prefix.clone(), + scopes_json: row.scopes_json.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + last_used_at_micros: row + .last_used_at + .map(|value| value.to_micros_since_unix_epoch()), + revoked_at_micros: row + .revoked_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_order_snapshot(row: &PaymentOrder) -> PaymentOrderSnapshot { + PaymentOrderSnapshot { + order_id: row.order_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + merchant_order_id: row.merchant_order_id.clone(), + idempotency_lookup: row.idempotency_lookup.clone(), + title: row.title.clone(), + items_json: row.items_json.clone(), + amount_cents: row.amount_cents, + currency: row.currency.clone(), + provider: row.provider, + provider_trade_no: row.provider_trade_no.clone(), + provider_code_url: row.provider_code_url.clone(), + callback_url: row.callback_url.clone(), + callback_signing_key_hash: row.callback_signing_key_hash.clone(), + provider_attempted_at_micros: row + .provider_attempted_at + .map(|value| value.to_micros_since_unix_epoch()), + checkout_token: row.checkout_token.clone(), + status: row.status, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + expires_at_micros: row.expires_at.to_micros_since_unix_epoch(), + paid_at_micros: row.paid_at.map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_webhook_snapshot(row: &PaymentWebhookDelivery) -> PaymentWebhookDeliverySnapshot { + PaymentWebhookDeliverySnapshot { + delivery_id: row.delivery_id.clone(), + order_id: row.order_id.clone(), + app_id: row.app_id.clone(), + owner_user_id: row.owner_user_id.clone(), + callback_url: row.callback_url.clone(), + payload_json: row.payload_json.clone(), + signature: row.signature.clone(), + status: row.status.clone(), + attempt_count: row.attempt_count, + available_at_micros: row.available_at.to_micros_since_unix_epoch(), + lease_worker_id: row.lease_worker_id.clone(), + lease_expires_at_micros: row + .lease_expires_at + .map(|value| value.to_micros_since_unix_epoch()), + last_error_message: row.last_error_message.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn payment_app_error(error_message: String) -> PaymentAppProcedureResult { + PaymentAppProcedureResult { + ok: false, + app: None, + key: None, + apps: Vec::new(), + keys: Vec::new(), + error_message: Some(error_message), + } +} + +fn payment_order_single_ok(order: PaymentOrderSnapshot) -> PaymentOrderProcedureResult { + PaymentOrderProcedureResult { + ok: true, + order: Some(order), + orders: Vec::new(), + error_message: None, + } +} + +fn payment_order_error(error_message: String) -> PaymentOrderProcedureResult { + PaymentOrderProcedureResult { + ok: false, + order: None, + orders: Vec::new(), + error_message: Some(error_message), + } +} + +fn payment_webhook_error(error_message: String) -> PaymentWebhookProcedureResult { + PaymentWebhookProcedureResult { + ok: false, + delivery: None, + deliveries: Vec::new(), + error_message: Some(error_message), + } +} + +fn required(value: String, field: &str) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(value) +} diff --git a/src/components/platform-entry/PaymentCheckoutView.tsx b/src/components/platform-entry/PaymentCheckoutView.tsx new file mode 100644 index 000000000..f1bf8a149 --- /dev/null +++ b/src/components/platform-entry/PaymentCheckoutView.tsx @@ -0,0 +1,199 @@ +import { + CheckCircle2, + Clock3, + Copy, + LoaderCircle, + ShieldCheck, +} from 'lucide-react'; +import QRCode from 'qrcode'; +import { useEffect, useState } from 'react'; + +import type { PaymentOrder } from '../../../packages/shared/src/contracts/payment'; +import { getPublicPaymentCheckout } from '../../services/payment/paymentCheckoutClient'; + +type PaymentCheckoutViewProps = { + checkoutToken: string; +}; + +export function PaymentCheckoutView({ + checkoutToken, +}: PaymentCheckoutViewProps) { + const [order, setOrder] = useState(null); + const [qrDataUrl, setQrDataUrl] = useState(''); + const [error, setError] = useState(''); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const response = await getPublicPaymentCheckout(checkoutToken); + if (!cancelled) { + setOrder(response.order); + setError(''); + } + } catch (nextError: unknown) { + if (!cancelled) { + setError( + nextError instanceof Error ? nextError.message : '读取支付订单失败', + ); + } + } + }; + void load(); + const timer = window.setInterval(() => { + if ( + order?.status === 'paid' || + order?.status === 'closed' || + order?.status === 'expired' + ) { + return; + } + void load(); + }, 5000); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [checkoutToken, order?.status]); + + useEffect(() => { + let cancelled = false; + if (!order?.providerQrCode) { + setQrDataUrl(''); + return; + } + QRCode.toDataURL(order.providerQrCode, { + width: 320, + margin: 2, + color: { dark: '#2c2723', light: '#ffffff' }, + }).then((dataUrl) => { + if (!cancelled) { + setQrDataUrl(dataUrl); + } + }); + return () => { + cancelled = true; + }; + }, [order?.providerQrCode]); + + const copyAmount = () => { + if (order) { + void navigator.clipboard?.writeText((order.amountCents / 100).toFixed(2)); + } + }; + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!order) { + return ( +
+
+ + 正在读取支付订单... +
+
+ ); + } + + const amount = (order.amountCents / 100).toFixed(2); + const isPaid = order.status === 'paid'; + const isClosed = order.status === 'closed' || order.status === 'expired'; + + return ( +
+
+
+
+ ¥ +
+ 扫码付款 + 支付成功后自动更新订单状态 +
+
+
+ 订单号 {order.merchantOrderId} +
+

{order.title}

+

+ 请核对商品与订单信息,使用右侧二维码完成支付。 +

+
+ 付款金额说明 + + 请完全按照页面显示的金额付款,少付或多付都可能导致支付无法自动确认。 + +
+
+ {isPaid ? ( + + ) : ( + + )} +
+ + {isPaid ? '支付成功' : isClosed ? '订单已关闭' : '等待支付'} + + + {isPaid + ? '平台已确认到账' + : isClosed + ? '请重新创建订单' + : '完成付款后页面会自动更新状态'} + +
+
+ +
+
+
+ 扫码付款 + {isPaid ? '已完成' : isClosed ? '已关闭' : '支付中'} +
+
+ 应付金额 + ¥{amount} +
+ {isPaid ? ( +
+ + 支付成功 +
+ ) : isClosed ? ( +
+ + 订单已关闭 +
+ ) : qrDataUrl ? ( + 支付二维码 + ) : ( +
+ +
+ )} +
+ + 微信支付 +
+

请使用微信扫一扫完成支付

+
+
+
+ ); +} diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx index 03ca20518..4ef7a27fa 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.tsx @@ -1,6 +1,7 @@ import { Camera, Coins, + CreditCard, History, KeyRound, MessageCircle, @@ -69,6 +70,7 @@ type PlatformActiveProfileViewProps = { mudPointBalance: ProfileMudPointBalanceView | null; onLogin: () => void; onOpenApiKeys: () => void; + onOpenPaymentService?: () => void; onOpenCommunity: () => void; onOpenFeedback: () => void; onOpenInvite: () => void; @@ -279,6 +281,7 @@ export function PlatformActiveProfileView({ mudPointBalance, onLogin, onOpenApiKeys, + onOpenPaymentService, onOpenCommunity, onOpenFeedback, onOpenInvite, @@ -663,6 +666,11 @@ export function PlatformActiveProfileView({ icon={KeyRound} onClick={onOpenApiKeys} /> + onOpenPaymentService?.()} + /> diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index f83811020..bedd30c2c 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -36,6 +36,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformSubpanel } from '../common/PlatformSubpanel'; import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry'; +import { PaymentCheckoutView } from './PaymentCheckoutView'; import { PlatformActiveMobileWelcomeDialog, shouldOpenActiveMobileWelcomeDialog, @@ -47,6 +48,7 @@ import { import { PlatformActiveProfileView } from './PlatformActiveProfileView'; import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes'; import { usePlatformDesktopLayout } from './platformEntryResponsive'; +import { PlatformPaymentServiceView } from './PlatformPaymentServiceView'; import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal'; import { canPasteFromNativeHostClipboard, @@ -366,6 +368,7 @@ export function PlatformEntryFlowShellImpl({ const isProfileStage = selectionStage === 'profile' || + selectionStage === 'profile-payment' || (!isDesktopLayout && selectionStage === 'platform'); const profileCenter = usePlatformProfileCenterController({ @@ -406,6 +409,10 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('profile', { path: '/profile' }); }, [setSelectionStage]); + const openPaymentService = useCallback(() => { + setSelectionStage('profile-payment', { path: '/profile/payment' }); + }, [setSelectionStage]); + const openGames = useCallback(() => { setSelectionStage('games', { path: '/games' }); }, [setSelectionStage]); @@ -578,6 +585,14 @@ export function PlatformEntryFlowShellImpl({ ); } + if (selectionStage === 'payment-checkout') { + const checkoutToken = + typeof window === 'undefined' + ? '' + : (window.location.pathname.split('/').filter(Boolean).at(-1) ?? ''); + return ; + } + const isAuthenticated = Boolean(authUi?.user); const balance = mudPointBalance?.totalPoints ?? legacyWalletBalance; const isCreationStage = @@ -773,7 +788,13 @@ export function PlatformEntryFlowShellImpl({ id={isCreationStage ? 'platform-tab-panel-create' : undefined} className={`platform-tab-panel min-h-0 min-w-0 flex-1 ${isGamePlayStage ? 'platform-tab-panel--game-play' : 'overflow-auto'} ${isCreationStage || isProfileStage || isGamePlayStage ? '' : 'px-3 py-4 sm:px-6 sm:py-6'}`} > - {isProfileStage ? ( + {selectionStage === 'profile-payment' ? ( + authUi?.openLoginModal()} + /> + ) : isProfileStage ? ( authUi?.openLoginModal()} onOpenApiKeys={() => setIsApiKeysOpen(true)} + onOpenPaymentService={openPaymentService} onOpenCommunity={() => profileCenter.openProfilePopupPanel('community') } diff --git a/src/components/platform-entry/PlatformPaymentServiceView.tsx b/src/components/platform-entry/PlatformPaymentServiceView.tsx new file mode 100644 index 000000000..9e91b88ac --- /dev/null +++ b/src/components/platform-entry/PlatformPaymentServiceView.tsx @@ -0,0 +1,347 @@ +import { + ArrowLeft, + Check, + Copy, + CreditCard, + ExternalLink, + KeyRound, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import type { AuthUser } from '@/packages/shared/src'; + +import type { + PaymentApiKey, + PaymentApp, + PaymentAppCreateResponse, +} from '../../../packages/shared/src/contracts/payment'; +import { + createPlatformProfilePaymentApp, + listPlatformProfilePaymentApps, + revokePlatformProfilePaymentApiKey, + updatePlatformProfilePaymentApp, +} from '../../services/platform-entry/platformProfileClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { PlatformSubpanel } from '../common/PlatformSubpanel'; + +type PlatformPaymentServiceViewProps = { + user: AuthUser | null | undefined; + onBack: () => void; + onLogin: () => void; +}; + +export function PlatformPaymentServiceView({ + user, + onBack, + onLogin, +}: PlatformPaymentServiceViewProps) { + const [apps, setApps] = useState([]); + const [keys, setKeys] = useState([]); + const [name, setName] = useState('我的游戏支付'); + const [callbackUrl, setCallbackUrl] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(''); + const [created, setCreated] = useState(null); + + useEffect(() => { + if (!user) { + return; + } + let cancelled = false; + setIsLoading(true); + listPlatformProfilePaymentApps() + .then((response) => { + if (!cancelled) { + setApps(response.apps ?? []); + setKeys(response.keys ?? []); + } + }) + .catch((nextError: unknown) => { + if (!cancelled) { + setError( + nextError instanceof Error ? nextError.message : '读取支付服务失败', + ); + } + }) + .finally(() => { + if (!cancelled) { + setIsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [user]); + + const submit = async () => { + const normalizedName = name.trim(); + if (!normalizedName) { + setError('请填写应用名称'); + return; + } + setIsCreating(true); + setError(''); + try { + const response = await createPlatformProfilePaymentApp( + normalizedName, + callbackUrl.trim() || null, + ); + setCreated(response); + setApps((current) => [response.app, ...current]); + } catch (nextError: unknown) { + setError( + nextError instanceof Error ? nextError.message : '创建支付应用失败', + ); + } finally { + setIsCreating(false); + } + }; + + const toggleApp = async (app: PaymentApp) => { + try { + const updated = await updatePlatformProfilePaymentApp( + app.appId, + !app.enabled, + ); + setApps((current) => + current.map((item) => (item.appId === updated.appId ? updated : item)), + ); + } catch (nextError: unknown) { + setError( + nextError instanceof Error ? nextError.message : '更新支付应用失败', + ); + } + }; + + const revokeKey = async (key: PaymentApiKey) => { + try { + const updated = await revokePlatformProfilePaymentApiKey(key.keyId); + setKeys((current) => + current.map((item) => (item.keyId === updated.keyId ? updated : item)), + ); + } catch (nextError: unknown) { + setError( + nextError instanceof Error + ? nextError.message + : '撤销支付 API Key 失败', + ); + } + }; + + const copyKey = async () => { + if (!created?.apiKey) { + return; + } + await navigator.clipboard?.writeText(created.apiKey); + }; + + if (!user) { + return ( +
+ + +

支付服务

+

+ 登录后可以创建游戏或外部产品的支付应用。 +

+ + 登录 + +
+
+ ); + } + + return ( +
+
+ +
+

+ 支付服务 +

+

+ 统一收款、动态二维码和支付结果通知。 +

+
+
+ + {error ? ( + + {error} + + ) : null} + + +
+ + +
+

创建接入应用

+

+ 为一款游戏或外部产品创建独立的应用身份。 +

+
+
+
+ + +
+ void submit()} + disabled={isCreating} + > + +
+ + {created ? ( + +
+ + 应用已创建 +
+

+ 完整 API Key 只显示这一次,请立即复制并保存到你的服务端。 +

+
+ + {created.apiKey} + + +
+
+ + {created.key.scopes.join('、')} +
+
+ ) : null} + + +
+

我的支付应用

+ + {isLoading ? '读取中...' : `${apps.length} 个应用`} + +
+
+ {apps.length === 0 && !isLoading ? ( +

+ 还没有支付应用。 +

+ ) : null} + {apps.map((app) => ( +
+
+ {app.name} + + {app.appId} + +
+
+ + {app.enabled ? '可用' : '已停用'} + +
+
+ ))} +
+
+ + +
+

支付 API Key

+ + {keys.length} 个 + +
+
+ {keys.length === 0 ? ( +

+ 创建应用后会生成默认 Key。 +

+ ) : null} + {keys.map((key) => ( +
+
+ {key.name} + + {key.keyPrefix} + +
+ +
+ ))} +
+
+ +

+ + 支付金额由服务端订单快照决定,到账以微信支付通知或查单为准。 +

+
+ ); +} diff --git a/src/components/platform-entry/platformEntryActiveTypes.ts b/src/components/platform-entry/platformEntryActiveTypes.ts index 862fbe957..46af48ad2 100644 --- a/src/components/platform-entry/platformEntryActiveTypes.ts +++ b/src/components/platform-entry/platformEntryActiveTypes.ts @@ -3,6 +3,8 @@ export type SelectionStage = | 'creation-home' | 'project' | 'profile' + | 'profile-payment' + | 'payment-checkout' | 'image-editor' | 'games' | 'game-detail' diff --git a/src/index.css b/src/index.css index 561c0d1bb..71eaa9a09 100644 --- a/src/index.css +++ b/src/index.css @@ -15,6 +15,71 @@ font-display: swap; } +.payment-checkout-page { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + background: radial-gradient(circle at 18% 12%, rgba(199, 101, 50, 0.11), transparent 32%), #f5f1ea; + color: #2c2723; +} + +.payment-checkout-shell { + width: min(68rem, 100%); + display: grid; + grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.15fr); + gap: 1.25rem; +} + +.payment-checkout-summary, +.payment-checkout-qr-panel { + border: 1px solid rgba(119, 92, 68, 0.13); + border-radius: 1.8rem; + background: rgba(255, 253, 249, 0.92); + box-shadow: 0 1.5rem 3.5rem rgba(92, 70, 49, 0.1); +} + +.payment-checkout-summary { padding: 2rem; } +.payment-checkout-qr-panel { padding: 2rem; text-align: center; } +.payment-checkout-brand { display: flex; align-items: center; gap: 0.75rem; } +.payment-checkout-brand > span { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 0.9rem; background: #f0e4d8; font-weight: 900; } +.payment-checkout-brand strong, .payment-checkout-brand small { display: block; } +.payment-checkout-brand small { margin-top: 0.2rem; color: #88796d; font-size: 0.76rem; } +.payment-checkout-code { margin-top: 2rem; color: #8c7d70; font-size: 0.78rem; } +.payment-checkout-code strong { display: block; margin-top: 0.35rem; color: #2c2723; font-size: 0.9rem; } +.payment-checkout-summary h1 { margin-top: 1.8rem; font-size: clamp(1.8rem, 4vw, 2.6rem); font-weight: 900; } +.payment-checkout-description { margin-top: 0.65rem; color: #75675c; font-size: 0.9rem; line-height: 1.7; } +.payment-checkout-alert { display: grid; gap: 0.45rem; margin-top: 1.7rem; padding: 1rem; border: 1px solid #f1d9d1; border-radius: 1rem; background: #fff7f4; color: #8f3e32; font-size: 0.82rem; line-height: 1.6; } +.payment-checkout-state { display: flex; align-items: center; gap: 0.75rem; margin-top: 1.1rem; padding: 1rem; border: 1px solid rgba(119, 92, 68, 0.1); border-radius: 1rem; } +.payment-checkout-state strong, .payment-checkout-state span { display: block; } +.payment-checkout-state span { margin-top: 0.2rem; color: #88796d; font-size: 0.78rem; } +.payment-checkout-copy { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 1rem; color: #7a5b42; font-size: 0.8rem; font-weight: 700; } +.payment-checkout-qr-title { display: flex; justify-content: space-between; font-size: 0.95rem; } +.payment-checkout-qr-title span { padding: 0.35rem 0.7rem; border: 1px solid rgba(119, 92, 68, 0.12); border-radius: 999px; color: #88796d; font-size: 0.75rem; } +.payment-checkout-amount { display: flex; align-items: center; justify-content: space-between; margin-top: 1.2rem; padding: 1.15rem; border: 1px solid rgba(119, 92, 68, 0.11); border-radius: 1rem; background: #f8f1e9; text-align: left; } +.payment-checkout-amount small { color: #88796d; } +.payment-checkout-amount strong { font-size: 2.1rem; } +.payment-checkout-qr, .payment-checkout-qr-placeholder { width: min(19rem, 100%); aspect-ratio: 1; margin: 1.8rem auto 1rem; border-radius: 1.2rem; background: #fff; } +.payment-checkout-qr { display: block; padding: 0.65rem; border: 1px solid rgba(119, 92, 68, 0.12); } +.payment-checkout-qr-placeholder { display: grid; place-items: center; color: #a17c5b; } +.payment-checkout-success { display: grid; place-items: center; gap: 0.7rem; min-height: 19rem; margin-top: 1.8rem; color: #27845d; } +.payment-checkout-success--muted { color: #998b7f; } +.payment-checkout-provider { display: inline-flex; align-items: center; gap: 0.35rem; font-weight: 800; } +.payment-checkout-hint { margin-top: 0.45rem; color: #88796d; font-size: 0.8rem; } +.payment-checkout-loading, .payment-checkout-error { display: flex; align-items: center; gap: 0.6rem; padding: 1rem 1.25rem; border-radius: 1rem; background: rgba(255, 253, 249, 0.95); box-shadow: 0 1rem 2rem rgba(92, 70, 49, 0.1); } +.payment-checkout-error { color: #8f3e32; } + +@media (max-width: 760px) { + .payment-checkout-page { align-items: flex-start; padding: 1rem; } + .payment-checkout-shell { grid-template-columns: 1fr; gap: 0.8rem; } + .payment-checkout-summary, .payment-checkout-qr-panel { border-radius: 1.25rem; padding: 1.25rem; } + .payment-checkout-summary h1 { margin-top: 1.25rem; font-size: 1.7rem; } + .payment-checkout-code { margin-top: 1.25rem; } + .payment-checkout-qr, .payment-checkout-qr-placeholder { margin-top: 1.25rem; } + .payment-checkout-success { min-height: 14rem; } +} + @theme { --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif; --font-serif: 'Noto Serif SC', 'Georgia', serif; diff --git a/src/routing/activeAppPageRoutes.test.ts b/src/routing/activeAppPageRoutes.test.ts index 11e141e2a..94b300a3e 100644 --- a/src/routing/activeAppPageRoutes.test.ts +++ b/src/routing/activeAppPageRoutes.test.ts @@ -30,6 +30,9 @@ describe('appPageRoutes', () => { expect(resolveSelectionStageFromPath('/games/publish')).toBe( 'game-publish', ); + expect(resolveSelectionStageFromPath('/pay/checkout-token')).toBe( + 'payment-checkout', + ); expect(resolveSelectionStageFromPath('/creation/rpg')).toBe('platform'); expect(resolveSelectionStageFromPath('/runtime/puzzle')).toBe('platform'); expect(isKnownMainAppPagePath('/creation')).toBe(true); @@ -42,6 +45,10 @@ describe('appPageRoutes', () => { expect(resolvePathForSelectionStage('games')).toBe('/games'); expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine'); expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish'); + expect(resolvePathForSelectionStage('profile-payment')).toBe( + '/profile/payment', + ); + expect(resolvePathForSelectionStage('payment-checkout')).toBe('/pay'); }); it('requires a project id for direct editor navigation', () => { diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 5da7357e4..8c91e2e6e 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -8,6 +8,8 @@ const STAGE_ROUTE_ENTRIES = [ ['creation-home', '/creation'], ['project', '/project'], ['profile', '/profile'], + ['profile-payment', '/profile/payment'], + ['payment-checkout', '/pay'], ['image-editor', '/editor/canvas'], ['games', '/games'], ['game-detail', '/games/detail'], @@ -39,6 +41,9 @@ export function normalizeAppPath(pathname: string) { export function resolveSelectionStageFromPath( pathname: string, ): SelectionStage { + if (normalizeAppPath(pathname).startsWith('/pay/')) { + return 'payment-checkout'; + } return ROUTE_STAGE_BY_PATH.get(normalizeAppPath(pathname)) ?? 'platform'; } @@ -69,7 +74,8 @@ export function shouldRedirectEditorCanvasWithoutProject( } export function isKnownMainAppPagePath(pathname: string) { - return ROUTE_STAGE_BY_PATH.has(normalizeAppPath(pathname)); + const normalized = normalizeAppPath(pathname); + return ROUTE_STAGE_BY_PATH.has(normalized) || normalized.startsWith('/pay/'); } export function pushAppHistoryPath(path: string) { diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts index 618713eab..d3a82f17d 100644 --- a/src/services/activeAppTitle.ts +++ b/src/services/activeAppTitle.ts @@ -9,6 +9,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = { 'creation-home': APP_HOME_TITLE, project: '项目 - 陶泥儿', profile: '我的 - 陶泥儿', + 'profile-payment': '支付服务 - 陶泥儿', + 'payment-checkout': '扫码支付 - 陶泥儿', 'image-editor': '美术编辑器 - 陶泥儿', games: '游戏 - 陶泥儿', 'game-detail': '游戏详情 - 陶泥儿', diff --git a/src/services/payment/paymentCheckoutClient.ts b/src/services/payment/paymentCheckoutClient.ts new file mode 100644 index 000000000..0438338e0 --- /dev/null +++ b/src/services/payment/paymentCheckoutClient.ts @@ -0,0 +1,11 @@ +import type { PaymentOrderCreateResponse } from '../../../packages/shared/src/contracts/payment'; +import { requestJson } from '../apiClient'; + +export function getPublicPaymentCheckout(checkoutToken: string) { + return requestJson( + `/api/payment/checkout/${encodeURIComponent(checkoutToken)}`, + { method: 'GET' }, + '读取支付订单失败', + { skipAuth: true, skipRefresh: true }, + ); +} diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts index f8a0aec1c..26f47fac2 100644 --- a/src/services/platform-entry/platformProfileClient.ts +++ b/src/services/platform-entry/platformProfileClient.ts @@ -1,3 +1,9 @@ +import type { + PaymentApiKey, + PaymentApp, + PaymentAppCreateResponse, + PaymentAppListResponse, +} from '../../../packages/shared/src/contracts/payment'; import type { ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse, @@ -142,6 +148,63 @@ export function revokePlatformProfileExternalApiKey( ); } +export function listPlatformProfilePaymentApps( + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/payment/apps', + { method: 'GET' }, + '读取支付服务失败', + options, + ); +} + +export function createPlatformProfilePaymentApp( + name: string, + callbackUrl: string | null, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + '/payment/apps', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, callbackUrl }), + }, + '创建支付应用失败', + options, + ); +} + +export function updatePlatformProfilePaymentApp( + appId: string, + enabled: boolean, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/payment/apps/${encodeURIComponent(appId)}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }, + '更新支付应用失败', + options, + ); +} + +export function revokePlatformProfilePaymentApiKey( + keyId: string, + options: PlatformProfileRequestOptions = {}, +) { + return requestPlatformProfileJson( + `/payment/keys/${encodeURIComponent(keyId)}`, + { method: 'DELETE' }, + '撤销支付 API Key 失败', + options, + ); +} + export function getPlatformProfileRechargeCenter( options: PlatformProfileRequestOptions = {}, ) {