diff --git a/.dockerignore b/.dockerignore index 422a9ea0..35a52aba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,6 +22,7 @@ tmp .env.secrets.* spacetime.local.json deploy/container/api-server.env +deploy/container/worker-smoke server-rs/target server-rs/target-* diff --git a/.gitignore b/.gitignore index 2885233d..9a953a92 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ temp*build*/ .env.secrets.local spacetime.local.json deploy/container/api-server.env +deploy/container/worker-smoke/ # Local load-test data extracted from private migration files scripts/loadtest/data/*.local.json diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 12557c80..8c73c631 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼 + +- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。 +- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。 +- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。 +- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 +- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 + +- 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。 +- 决策:新增 `server-rs/crates/server-manager-panel` 作为本地 egui 桌面工具;服务器来源只读取本机 `~/.ssh/config` 的具体 `Host` alias,不保存服务器密钥或凭据;巡检通过 `ssh sh -s` 执行只读脚本,服务操作只允许 `start`、`stop`、`restart` 并限制 systemd unit 名字符集。 +- 影响范围:本地运维工具入口、`package.json` 的 `server-manager:panel`、开发运维文档和团队共享工作流。 +- 验证方式:`cargo check -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`cargo test -p server-manager-panel --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md`。 + ## 2026-06-10 公开作品互动能力进入后台全局配置 - 背景:作品详情页的点赞和改造能力原本由前端和各玩法 handler 的硬编码能力矩阵决定,后台无法临时关闭某类公开作品的互动入口,直接关闭创作入口又会误伤已有作品读取和游玩。 @@ -32,6 +48,13 @@ - 验证方式:从 release 执行 `git ls-remote http://10.2.0.10/GenarrativeAI/Genarrative.git HEAD` 应返回 HEAD;公网来源伪造 `Host: 10.2.0.10` 访问 dev 公网 80 应返回 `403`;`https://git.genarrative.world/` 原入口应保持 `200`。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-06-08 通用分享统一为作品分享卡片 + +- 背景:已发布作品的分享入口需要同时支持网页复制链接、下载可传播的分享卡,以及微信小程序内的九宫切图;推荐页在小程序内直接使用系统“分享到聊天”时,宿主快照只截页面中部,容易裁掉游戏主体,且原生分享默认只能拿到小程序页面启动参数。 +- 决策:统一分享入口继续收口到 `PublishShareModal`,分享卡展示作品封面、作品类型、作品名称和公开作品号,底部提供“复制链接”和“下载卡片”。普通 H5 复制公开作品 H5 URL;微信小程序 WebView 内复制小程序 `pages/web-view/index` 路径,缺少直达参数时补 `targetPath=/works/detail` 与 `work=<公开作品号>`,由小程序原生 WebView 页转成 H5 作品详情 URL。当 H5 运行在微信小程序 WebView 内且存在封面图时,额外显示“九宫切图”,跳转小程序原生 `pages/share-grid/index`,由原生页按 3x3 从左到右、从上到下裁切并保存。推荐页当前作品会通过 `wx.miniProgram.postMessage` 同步给小程序原生 `web-view` 页,右上角系统分享优先使用该目标生成带作品参数的小程序路径。小程序运行态通过根节点标记启用推荐页 runtime 快照安全区,把游戏画面等比缩放到分享快照中部。 +- 影响范围:`src/components/common/PublishShareModal.tsx`、`src/components/common/publishShareModalModel.ts`、`src/components/common/publishShareCardImage.ts`、`src/services/wechatMiniProgramShareGrid.ts`、`src/services/wechatMiniProgramShareTarget.ts`、`miniprogram/pages/web-view/`、`miniprogram/pages/share-grid/`、推荐页 runtime CSS 和平台玩法链路文档。 +- 验证方式:`npm run test -- src/components/common/PublishShareModal.test.tsx miniprogram/pages/web-view/index.test.js src/services/wechatMiniProgramShareTarget.test.ts`、`npm run test -- miniprogram/pages/share-grid/index.test.js`、`npm run test -- src/index.test.ts -t "mini program recommend runtime"`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-06-08 微信能力按领域收口 - 背景:微信登录、订阅消息、普通微信支付和小程序虚拟支付能力曾分散在 `api-server` 根模块、`platform-auth` 与 `platform-wechat`,支付协议细节和业务 handler 边界不够清晰。 @@ -48,6 +71,415 @@ - 验证方式:`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`,并确认旧 `/api/creation//*`、历史 `/api/runtime//agent/*` 与公开 runtime 路由外部契约不变。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-08 PlatformUiKit 弹窗与复制反馈收口 + +- 背景:前端已有 `UnifiedModal` 统一遮罩和无障碍外壳,但业务页面仍反复手写“知道了”“确认 / 取消”“危险确认”的 footer 按钮和关闭禁用逻辑。 +- 决策:简单提示、确认 / 取消和危险确认统一使用 `src/components/common/UnifiedConfirmDialog.tsx`;剪贴板复制反馈统一使用 `src/components/common/useCopyFeedback.ts`,可点击复制按钮统一使用 `src/components/common/CopyFeedbackButton.tsx` 承载图标、三态文案、可访问名称、纯图标模式和动作按钮外观入口,作品号 / 用户号等短代码 chip 统一使用 `src/components/common/CopyCodeButton.tsx` 承载代码、三态后缀和默认可访问名称,非按钮复制提示统一使用 `src/components/common/CopyFeedbackMessage.tsx`,白底平台状态提示统一使用 `src/components/common/PlatformStatusMessage.tsx`,无操作空态 / 轻量读取态统一使用 `src/components/common/PlatformEmptyState.tsx`,平台动作按钮统一使用 `src/components/common/PlatformActionButton.tsx` 承载 platform / profile 两类样式族、尺寸、圆角、对齐、宽度和禁用态;认证表单的提交、验证码、第三方登录和邀请码提交按钮使用 `size="lg"` 复用 48px 高度,统一创作工作台、统一创作页壳层、玩法创作工作台、结果页返回按钮和反馈页 header 返回使用 `tone="ghost"`,生成 / 提交 / 发布按钮使用主动作,自定义世界实体目录、RPG 首页作品卡删除、创作中心错误重试和素材槽的小动作使用 `size="xs"` 或 `shape="pill"` 收口,推荐回复和列表内动作使用 `align="start"` 承接左对齐,上传控件等需要 label 语义时使用 `PlatformActionButton asChild="label"`,不把文件输入伪装成普通 button。普通平台图标动作按钮和图标上传 label 统一使用 `src/components/common/PlatformIconButton.tsx` 承载 `platform-icon-button` 外观、可访问名称、默认 `type="button"`、`asChild="label"` 和可选 title;历史图片选择弹窗、RPG 发布检查弹窗、RPG 首页搜索结果清空、creative-agent 侧边栏关闭 / 外观 / 设置入口、creation-agent 参考图移除、敲木鱼结果页新增主题标签入口、拼图结果页标签生成 / 标签新增 / 关卡详情关闭 / 发布弹窗关闭 / 删除关卡入口、视觉小说结果页素材选择 / 音频生成 / 保存草稿 / 运行配置入口,以及抓大鹅结果页标签生成 / 标签新增 / 物品素材删除 / 参考图上传入口已先迁移;图标上传控件必须保留 label + file input 语义。平台 / 个人中心弹窗关闭按钮统一使用 `src/components/common/PlatformModalCloseButton.tsx` 承载 profile / profileCompact / floating / floatingPlain / platformIcon 五类圆形关闭按钮、默认图标和可访问名称;认证入口、邀请码弹窗、抓大鹅结果页弹窗关闭等平台头部关闭按钮使用 `variant="platformIcon"`,不在业务 JSX 中手写 `platform-icon-button` + X 图标。RPG / 拼图 / 抓大鹅 / 跳一跳 / 敲木鱼 / 拼消消 / 宝贝识物 / 方洞 / 汪汪声浪结果页,拼消消 / 宝贝识物 / 视觉小说 / 汪汪声浪创作工作台,发布检查、素材生成面板和自定义世界实体目录中的错误 / 成功 / 信息 / 警告 / 中性提示使用 `PlatformStatusMessage surface="platform"` 复用平台 banner token;个人中心弹窗、账号安全弹窗、认证入口、验证码提示、统一创作工作台和通用创作输入区的错误 / 成功 / 信息 / 警告提示使用 `PlatformStatusMessage surface="profile"` 复用 profile token,不再把 `platform-profile-error` / `platform-profile-success` 或 `platform-banner--danger / success / info / warning / neutral` 作为业务 JSX 接口。`UnifiedModal` 继续作为底层模态窗口 Module。已有弹窗栈内的二级确认使用 `UnifiedConfirmDialog portal={false}` 内嵌到当前层级。特殊确认按钮外观通过 `confirmClassName` 适配,不让业务页重新手写 footer;`UnifiedConfirmDialog` 自身的 footer 按钮也复用 `PlatformActionButton`。带复制状态、渠道按钮、媒体预览或复杂网格的弹窗可以保留专用 Module,但普通确认按钮、普通动作按钮、普通图标动作按钮、复制按钮动作外观、复制状态机、copied / failed 按钮 / toast 分支、基础错误 / 成功提示条、无操作空态和普通弹窗关闭按钮不再直接写进业务页面。运行态 HUD、输入 Composer 发送 / 上传按钮、复制三态图标按钮或需要专用交互禁用语义的图标按钮先保留专用布局,等对应场景验证时再迁移。业务代码中的阻断提示、删除确认和公开作品失效恢复不得继续调用浏览器原生 `window.alert` / `window.confirm`,应由页面壳层或编辑器壳层用 `UnifiedConfirmDialog` 承接。简单确认需要像素风时使用 `UnifiedConfirmDialog variant="pixel"`,不再为同类确认单独维护壳层和按钮。 +- 2026-06-10 追加:推荐页运行态卡片底部的点赞 / 分享 / 改造入口,以及创作中心公开作品卡右上角分享入口统一迁移到 `PlatformIconButton`;这类和 swipe / drag 手势耦合的图标动作必须继续保留业务局部 class 与 `onPointerDown` / `onClick` 里的 `stopPropagation`,只把按钮语义、可访问名称和默认 `type="button"` 收口到共享组件,避免图标动作误触推荐卡切换、整卡打开或残留左滑状态。 +- 2026-06-10 追加:标准泥点消耗确认弹窗统一收口到 `src/components/common/PlatformMudPointConfirmDialog.tsx`;该 Module 专门承接“确认消耗泥点 + 消耗 N 泥点”的同形态确认骨架,当前已覆盖 `PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx`、`PuzzleResultView.tsx` 与 `Match3DResultView.tsx`。后续遇到同形态泥点确认时,业务页只传点数、补充说明和确认回调,不再重复拼接 `UnifiedConfirmDialog` 正文;`RpgCreationRoleAssetStudioModalImpl` 这类节奏和内容结构不同的泥点弹层继续单独评估,留作后续轮次处理。 +- 2026-06-10 追加:`RpgCreationRoleAssetStudioModalImpl.tsx` 的角色形象生成 / 动作草稿生成确认也并入 `PlatformMudPointConfirmDialog`;共享组件通过自定义 title 与补充说明承接工坊语义,工坊页不再单独维护 `UnifiedConfirmDialog` 的标准泥点文案骨架。后续同类“确认消耗泥点 + 补充说明”场景继续优先复用该 Module。 +- 2026-06-10 追加:平台危险确认统一收口到 `src/components/common/PlatformDangerConfirmDialog.tsx`;该 Module 专门承接“确认 / 取消 + 危险主动作”的标准骨架,当前已覆盖 `PlatformEntryFlowShellImpl.tsx` 的删除作品确认、`RpgCreationResultViewImpl.tsx` 的重新生成确认和 `CustomWorldEntityCatalog.tsx` 的删除角色 / 批量删除确认。后续删除、覆盖、清空等危险动作优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + confirmTone=\"danger\"` 组合。 +- 2026-06-10 追加:平台未保存离开确认统一收口到 `src/components/common/PlatformUnsavedLeaveConfirmDialog.tsx`;该 Module 专门承接“继续编辑 + 确认离开”的标准骨架,当前已覆盖 `RpgCreationEntityEditorShared.tsx` 里的关闭未保存修改、生成结果未保存退出和普通结果未保存退出确认。后续同类未保存离开场景优先复用该 Module,不再在业务页重复拼接 `UnifiedConfirmDialog` 的 `showCancel + cancelLabel=\"继续编辑\"` 组合和重复壳层 class。 +- 2026-06-10 追加:平台单按钮已读状态统一收口到 `src/components/common/PlatformAcknowledgeStatusDialog.tsx`;该 Module 专门承接“状态提示 + 知道了”的单按钮确认已读语义,当前已覆盖 `BigFishResultView.tsx` 的发布失败提示、`RpgEntryHomeView.tsx` 的支付结果提示、`RpgCreationEntityEditorShared.tsx` 的编辑器 notice、`PlatformEntryFlowShellImpl.tsx` 的泥点提示 / 作品不可用 / 搜索未命中提示,以及 `CustomWorldEntityCatalog.tsx` 的“无法删除”阻断提示。后续同类 status-dialog 场景优先复用该 Module,不再在业务页重复拼装 `action={{ label: '知道了', onClick: onClose }}`。 +- 2026-06-10 追加:RPG 首页个人中心里的统计卡、统计骨架、常用功能入口、设置行和法律信息入口统一抽到 `src/components/platform-entry/PlatformProfilePrimitives.tsx`;这组纯展示原子以后优先通过 props 接收图片资源、点击回调和展示文案,不再继续塞回 `RpgEntryHomeView` 的账户控制逻辑里。新建 `PlatformProfilePrimitives.test.tsx` 作为组件级护栏,页面级布局与法律入口继续由 `RpgEntryHomeView.recharge.test.tsx` 兜底。 +- 2026-06-10 追加:RPG 首页个人中心的充值 / 钱包 / 每日任务 / 邀请 / 兑换码等商业与账户控制逻辑统一收口到 `src/components/platform-entry/usePlatformProfileCenterController.ts`;controller 负责账户动作分流、商业状态派生与相关面板控制,`RpgEntryHomeView` 只保留展示、昵称头像编辑、扫码入口和页面级交互编排,不在页面组件里继续堆叠账户控制分支。验证命令:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 +- 2026-06-10 追加:RPG 首页个人中心的“玩过 / 可继续”历史弹层统一抽到 `src/components/platform-entry/PlatformProfilePlayedWorksModal.tsx`;`RpgEntryHomeView` 不再内联 `SaveArchiveCard`、`ProfilePlayedWorksModal` 和未连通的 `ProfileSaveArchivesModal`。当前产品语义已经把存档恢复并入“玩过”弹层的“可继续”分区,因此 controller 里的 `ProfilePopupPanel` 也去掉了没有真实入口的 `saveArchives` 分支。验证命令:`npm run test -- src/components/platform-entry/PlatformProfilePlayedWorksModal.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`npm run typecheck`。 +- 2026-06-10 追加:个人中心标准头部弹窗与白底副弹层的共享壳层统一抽到 `src/components/platform-entry/PlatformProfileModalShell.tsx`;标准头部弹窗优先复用 `PlatformProfileModalShell`,白底副弹层优先复用 `PlatformProfileSecondaryModalShell`,不再在业务页重复手写 profile overlay、header、title、description、floating close 和关闭策略。昵称修改、账户充值、每日任务、兑换码、泥点账单、“玩过 / 可继续”以及邀请相关弹层已接入这套壳层。 +- 2026-06-10 追加:RPG 首页个人中心的邀请好友 / 填邀请码 / 玩家社区三态弹层统一抽到 `src/components/platform-entry/PlatformProfileReferralModal.tsx`;首页不再内联邀请码规范化、社区二维码卡片和邀请用户头像行,后续 profile 侧同类二级弹层优先按“独立组件 + `PlatformProfileSecondaryModalShell`”继续收口。 +- 2026-06-10 追加:RPG 首页个人中心的账户充值弹层统一抽到 `src/components/platform-entry/PlatformProfileRechargeModal.tsx`;充值 tab、套餐卡片、Native 二维码生成和确认支付入口不再内联在 `RpgEntryHomeView`,后续 profile 侧充值入口优先复用同一个组件。 +- 2026-06-10 追加:RPG 首页个人中心的泥点账单、每日任务和兑换码弹层统一抽到 `src/components/platform-entry/PlatformProfileWalletLedgerModal.tsx`、`src/components/platform-entry/PlatformProfileTaskCenterModal.tsx` 与 `src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx`;`RpgEntryHomeView` 只保留打开条件和数据流,标准 profile 弹层内容以后优先沉到 `platform-entry` 独立组件,不在首页继续堆叠。 +- 2026-06-10 追加:个人中心支付结果提示与支付确认遮罩统一抽到 `src/components/common/PlatformStatusDialog.tsx`,扫码面板统一抽到 `src/components/platform-entry/PlatformProfileQrScannerModal.tsx`;`RpgEntryHomeView` 只保留支付结果 kind 到 `success / loading / cancel / error` 的映射、确认遮罩开关和扫码结果写回,不再内联 profile 状态弹层壳层、二维码摄像头启动或 `BarcodeDetector` 轮询。后续 profile 侧同类“状态图标 + 标题正文 + 可选主动作”弹层优先复用 `PlatformStatusDialog`,扫码类弹层优先复用 `PlatformProfileQrScannerModal`。 +- 2026-06-10 追加:`PlatformStatusDialog` 支持自定义图标、图标可访问标签以及动作按钮 surface / size / className 透传,用来承接玩法结果页里保留品牌视觉但语义仍是“状态结果弹层”的场景;大鱼吃小鱼结果页的发布失败弹层已迁移到这套组件,业务页不再保留 `UnifiedConfirmDialog + PlatformIconBadge` 的专用组合。 +- 2026-06-10 追加:`PlatformStatusDialog` 继续支持 header notice 布局、body content、close button、backdrop / Escape 关闭路径,用来承接“提示 / 规则阻断 / 作品不可用 / 泥点不足”这类带标题栏的状态 notice;平台入口的 `draftGenerationPointNotice`、`workNotFoundRecoveryDialog` 和 RPG 大编辑器里的 `EditorNoticeDialog` 已迁移到这套共享组件,不再各自维护 `UnifiedConfirmDialog` 壳层和关闭策略。 +- 2026-06-10 追加:`CustomWorldEntityCatalog` 的 `minimum-playable` 规则阻断提示也统一迁到 `PlatformStatusDialog`,不再和删除角色 / 批量删除共用 `UnifiedConfirmDialog` 配置;同日平台入口公开编号搜索把 error 分支从用户摘要 modal 中拆出,未命中结果单独走 `PlatformStatusDialog`,命中用户继续保留 `UnifiedModal + PlatformSubpanel` 信息布局。 +- 2026-06-11 追加:`PlatformAsyncStatePanel` 继续从 profile modal 与作品架扩展到 RPG 首页公开分区;`RpgEntryHomeView.tsx` 的移动端排行、发现页寓教于乐 / 默认公开 feed、桌面首页“今日游戏 / 推荐”、桌面发现页寓教于乐 / 默认公开 feed,以及“我的创作”分区已统一改成 `loadingState / emptyState / children` 三态 slot。页面级 `platformError` 继续留在状态壳外层,保证错误提示可以和内容并存;`recommend runtime`、分类筛选等含运行态或二级筛选语义的分支暂不硬并入这一轮。 +- 2026-06-11 追加:暗色 / 像素 modal 的标准 footer 布局统一抽到 `src/components/common/PlatformDarkModalFooter.tsx`;该组件只负责 dark footer 的分隔线、padding 和常见动作区排布,不持有“取消 / 确认”业务语义。`NpcModals.tsx` 的交易 / 赠礼 / 招募 footer、`SelectionCustomizationModals.tsx` 的 `SelectionModal` footer、`RpgAdventurePanelOverlays.tsx` 的 goal panel footer,以及 `InventoryItemViews.tsx` 的详情 footer wrapper 已接入;sticky 工作台 footer、正文内单 CTA 收尾和 runtime HUD 工具条暂不并入这一抽象。 +- 2026-06-11 追加:桌面首页里的轻量可点击扁平行开始统一收口到 `src/components/common/PlatformNavigableListItem.tsx`;目前已覆盖 `RpgEntryHomeView.tsx` 的搜索结果行、桌面“最近作品”、桌面“最近浏览”以及桌面“今日游戏”趋势行。组件只承接 `button + left content + right affordance` 结构、默认 `type="button"` 与 `leading / trailing` 插槽,暂不扩成覆盖教培 promo card、分类卡片、世界卡或 runtime 列表项的万能 row primitive。 +- 2026-06-11 追加:`PlatformNavigableListItem` 继续扩展到 profile 设置行;`src/components/platform-entry/PlatformProfilePrimitives.tsx` 的 `ProfileSettingsRow` 已改成委托共享 `button + leading + trailing` 骨架,继续保留本地 `platform-profile-settings-row` class 承接分隔线、icon 胶囊和字号微调。后续 profile / 账户中心里的同类轻量导航行优先直接复用共享行骨架,不再回退成原生 ` + + + diff --git a/miniprogram/pages/share-grid/index.wxss b/miniprogram/pages/share-grid/index.wxss new file mode 100644 index 00000000..e34ca465 --- /dev/null +++ b/miniprogram/pages/share-grid/index.wxss @@ -0,0 +1,60 @@ +page { + background: #fffdf9; +} + +.share-grid-page { + min-height: 100vh; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 48rpx; + background: #fffdf9; +} + +.share-grid-card { + width: 100%; + max-width: 560rpx; + box-sizing: border-box; + border: 1rpx solid rgba(127, 85, 57, 0.18); + border-radius: 16rpx; + background: rgba(255, 255, 255, 0.92); + padding: 36rpx; + box-shadow: 0 24rpx 68rpx rgba(127, 85, 57, 0.12); +} + +.share-grid-title { + color: #332820; + font-size: 34rpx; + font-weight: 700; + line-height: 1.35; +} + +.share-grid-text { + margin-top: 18rpx; + color: rgba(51, 40, 32, 0.68); + font-size: 26rpx; + line-height: 1.55; +} + +.share-grid-text--danger { + color: #b84a3d; +} + +.share-grid-button { + margin-top: 28rpx; + width: 100%; + border-radius: 8rpx; + background: #7f5539; + color: #fffdf9; + font-size: 28rpx; + line-height: 2.6; +} + +.share-grid-canvas { + position: fixed; + left: -9999px; + top: -9999px; + width: 1px; + height: 1px; +} diff --git a/miniprogram/pages/web-view/index.js b/miniprogram/pages/web-view/index.js index c7d221dc..b417a495 100644 --- a/miniprogram/pages/web-view/index.js +++ b/miniprogram/pages/web-view/index.js @@ -10,6 +10,13 @@ const { WEB_VIEW_ENTRY_URL, WEB_VIEW_SOURCE_QUERY, } = require('../../config'); +const { + appendHashParams, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = require('./index.shared'); const MINI_PROGRAM_CLIENT_TYPE = 'mini_program'; const MINI_PROGRAM_CLIENT_RUNTIME = 'wechat_mini_program'; @@ -19,7 +26,6 @@ const AUTH_RESULT_STORAGE_KEY = 'genarrative:mini-program-auth-result'; const AUTH_ACTION_LOGIN = 'login'; const PAY_RESULT_RECHECK_DELAY_MS = 120; const WEB_VIEW_SHARE_TITLE = '陶泥儿'; -const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; function showWebViewShareMenu() { if (typeof wx.showShareMenu !== 'function') { @@ -32,17 +38,25 @@ function showWebViewShareMenu() { }); } -function buildWebViewShareAppMessage() { +function resolveNativeShareQuery(page) { + return ( + (page && page._currentShareTarget) || + (page && page._lastLaunchQuery) || + {} + ); +} + +function buildWebViewShareAppMessage(query = {}) { return { title: WEB_VIEW_SHARE_TITLE, - path: WEB_VIEW_SHARE_PATH, + path: buildWebViewSharePath(query), }; } -function buildWebViewShareTimeline() { +function buildWebViewShareTimeline(query = {}) { return { title: WEB_VIEW_SHARE_TITLE, - query: '', + query: buildWebViewShareTimelineQuery(query), }; } @@ -59,50 +73,6 @@ function isConfiguredApiBaseUrl(value) { return /^https:\/\/[^/]+/i.test(String(value || '').trim()); } -function appendQuery(url, query) { - const pairs = Object.keys(query) - .filter((key) => query[key]) - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`, - ); - - if (pairs.length === 0) { - return url; - } - - return `${url}${url.includes('?') ? '&' : '?'}${pairs.join('&')}`; -} - -function appendHashParams(url, params) { - const nextKeys = new Set(Object.keys(params).filter((key) => params[key])); - const pairs = Object.keys(params) - .filter((key) => params[key]) - .map( - (key) => - `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`, - ); - if (pairs.length === 0) { - return url; - } - - const hashIndex = url.indexOf('#'); - const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url; - const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : ''; - const keptHashParts = rawHash.split('&').filter((part) => { - if (!part) { - return false; - } - const [rawKey = ''] = part.split('='); - try { - return !nextKeys.has(decodeURIComponent(rawKey)); - } catch (_error) { - return !nextKeys.has(rawKey); - } - }); - return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`; -} - function parseBooleanQueryFlag(value) { return value === true || value === '1' || value === 'true' || value === 'yes'; } @@ -233,22 +203,16 @@ function shouldReturnToPreviousPage(query) { return String((query && query.returnTo) || '').trim() === 'previous'; } -function resolveWebViewUrl(authResult) { +function resolveWebViewUrl(authResult, launchQuery = {}) { const runtimeConfig = resolveMiniProgramRuntimeConfig(); const entryUrl = String(runtimeConfig.webViewEntryUrl || '').trim(); if (!isConfiguredEntryUrl(entryUrl)) { return ''; } - const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery); - if (!authResult || !authResult.token) { - return sourcedUrl; - } - - return appendHashParams(sourcedUrl, { - auth_provider: 'wechat', - auth_token: authResult.token, - auth_binding_status: authResult.bindingStatus, + return resolveWebViewUrlFromRuntimeConfig(authResult, launchQuery, { + ...runtimeConfig, + webViewEntryUrl: String(runtimeConfig.webViewEntryUrl || '').trim(), }); } @@ -467,7 +431,7 @@ Page({ loading: false, phoneBindingRequired: false, returnToPreviousPage: false, - webViewUrl: resolveWebViewUrl(null), + webViewUrl: resolveWebViewUrl(null, query), }); return; } @@ -572,7 +536,7 @@ Page({ nicknameRequired: false, phoneBindingRequired: false, returnToPreviousPage, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } catch (error) { this.setData({ @@ -600,7 +564,7 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(authResult), + webViewUrl: resolveWebViewUrl(authResult, this._lastLaunchQuery || {}), }); } @@ -674,7 +638,10 @@ Page({ loading: false, nicknameRequired: false, phoneBindingRequired: false, - webViewUrl: resolveWebViewUrl(nextAuthResult), + webViewUrl: resolveWebViewUrl( + nextAuthResult, + this._lastLaunchQuery || {}, + ), }); } catch (error) { this.setData({ @@ -712,15 +679,19 @@ Page({ }, handleWebViewMessage(event) { + const shareTarget = resolveShareTargetFromWebViewMessage(event.detail); + if (shareTarget) { + this._currentShareTarget = shareTarget; + } // 中文注释:支付和订阅消息都由独立 native 页面承接,web-view 消息只保留调试输出。 console.info('[web-view] message', event.detail); }, onShareAppMessage() { - return buildWebViewShareAppMessage(); + return buildWebViewShareAppMessage(resolveNativeShareQuery(this)); }, onShareTimeline() { - return buildWebViewShareTimeline(); + return buildWebViewShareTimeline(resolveNativeShareQuery(this)); }, }); diff --git a/miniprogram/pages/web-view/index.shared.js b/miniprogram/pages/web-view/index.shared.js new file mode 100644 index 00000000..5f05096a --- /dev/null +++ b/miniprogram/pages/web-view/index.shared.js @@ -0,0 +1,188 @@ +const ALLOWED_TARGET_PATHS = new Set(['/works/detail']); +const SHARE_TARGET_MESSAGE_TYPE = 'genarrative:share-target'; +const WEB_VIEW_SHARE_PATH = '/pages/web-view/index'; + +function trimTrailingSlash(value) { + return String(value || '').trim().replace(/\/+$/u, ''); +} + +function appendQuery(url, query) { + const rawUrl = String(url || ''); + const pairs = Object.keys(query) + .filter((key) => query[key]) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(query[key]))}`, + ); + + if (pairs.length === 0) { + return rawUrl; + } + + const hashIndex = rawUrl.indexOf('#'); + const baseUrl = hashIndex >= 0 ? rawUrl.slice(0, hashIndex) : rawUrl; + const hash = hashIndex >= 0 ? rawUrl.slice(hashIndex) : ''; + return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${pairs.join('&')}${hash}`; +} + +function appendHashParams(url, params) { + const nextKeys = new Set(Object.keys(params).filter((key) => params[key])); + const pairs = Object.keys(params) + .filter((key) => params[key]) + .map( + (key) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`, + ); + if (pairs.length === 0) { + return url; + } + + const hashIndex = url.indexOf('#'); + const baseUrl = hashIndex >= 0 ? url.slice(0, hashIndex) : url; + const rawHash = hashIndex >= 0 ? url.slice(hashIndex + 1) : ''; + const keptHashParts = rawHash.split('&').filter((part) => { + if (!part) { + return false; + } + const [rawKey = ''] = part.split('='); + try { + return !nextKeys.has(decodeURIComponent(rawKey)); + } catch (_error) { + return !nextKeys.has(rawKey); + } + }); + return `${baseUrl}#${keptHashParts.concat(pairs).join('&')}`; +} + +function normalizeTargetPath(value) { + const trimmed = String(value || '').trim(); + if (!trimmed.startsWith('/')) { + return ''; + } + + const normalized = trimmed.replace(/\/+$/u, '') || '/'; + return ALLOWED_TARGET_PATHS.has(normalized) ? normalized : ''; +} + +function resolveLaunchTargetQuery(query) { + const targetPath = normalizeTargetPath(query && query.targetPath); + const work = String((query && query.work) || '').trim(); + if (!targetPath || !work) { + return {}; + } + + return { + targetPath, + work, + }; +} + +function buildWebViewSharePath(query = {}, basePath = WEB_VIEW_SHARE_PATH) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return basePath; + } + + return appendQuery(basePath, { + targetPath: launchTarget.targetPath, + work: launchTarget.work, + }); +} + +function buildWebViewShareTimelineQuery(query = {}) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return ''; + } + + return new URLSearchParams({ + targetPath: launchTarget.targetPath, + work: launchTarget.work, + }).toString(); +} + +function normalizeShareTargetMessageData(value) { + const message = value && value.data ? value.data : value; + if (!message || message.type !== SHARE_TARGET_MESSAGE_TYPE) { + return null; + } + + const payload = message.payload || {}; + const launchTarget = resolveLaunchTargetQuery(payload); + if (!launchTarget.targetPath) { + return null; + } + + return { + ...launchTarget, + title: String(payload.title || '').trim(), + }; +} + +function resolveShareTargetFromWebViewMessage(detail) { + const dataList = detail && Array.isArray(detail.data) ? detail.data : []; + for (let index = dataList.length - 1; index >= 0; index -= 1) { + const target = normalizeShareTargetMessageData(dataList[index]); + if (target) { + return target; + } + } + + return normalizeShareTargetMessageData(detail); +} + +function appendLaunchTargetToEntryUrl(entryUrl, query) { + const launchTarget = resolveLaunchTargetQuery(query); + if (!launchTarget.targetPath) { + return entryUrl; + } + + const rawEntryUrl = String(entryUrl || '').trim(); + const hashIndex = rawEntryUrl.indexOf('#'); + const entryWithoutHash = + hashIndex >= 0 ? rawEntryUrl.slice(0, hashIndex) : rawEntryUrl; + const hash = hashIndex >= 0 ? rawEntryUrl.slice(hashIndex) : ''; + const queryIndex = entryWithoutHash.indexOf('?'); + const entryBase = + queryIndex >= 0 ? entryWithoutHash.slice(0, queryIndex) : entryWithoutHash; + const entrySearch = + queryIndex >= 0 ? entryWithoutHash.slice(queryIndex) : ''; + const targetUrl = `${trimTrailingSlash(entryBase)}${launchTarget.targetPath}${entrySearch}${hash}`; + + return appendQuery(targetUrl, { + work: launchTarget.work, + }); +} + +function resolveWebViewUrlFromRuntimeConfig( + authResult, + launchQuery = {}, + runtimeConfig = {}, +) { + const entryUrl = appendLaunchTargetToEntryUrl( + String(runtimeConfig.webViewEntryUrl || '').trim(), + launchQuery, + ); + const sourcedUrl = appendQuery(entryUrl, runtimeConfig.sourceQuery || {}); + if (!authResult || !authResult.token) { + return sourcedUrl; + } + + return appendHashParams(sourcedUrl, { + auth_provider: 'wechat', + auth_token: authResult.token, + auth_binding_status: authResult.bindingStatus, + }); +} + +module.exports = { + appendHashParams, + appendLaunchTargetToEntryUrl, + appendQuery, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + normalizeTargetPath, + resolveShareTargetFromWebViewMessage, + resolveLaunchTargetQuery, + resolveWebViewUrlFromRuntimeConfig, +}; diff --git a/miniprogram/pages/web-view/index.test.js b/miniprogram/pages/web-view/index.test.js new file mode 100644 index 00000000..a04adbc5 --- /dev/null +++ b/miniprogram/pages/web-view/index.test.js @@ -0,0 +1,110 @@ +import { describe, expect, test } from 'vitest'; + +import webViewBridge from './index.shared.js'; + +const { + appendLaunchTargetToEntryUrl, + buildWebViewSharePath, + buildWebViewShareTimelineQuery, + resolveShareTargetFromWebViewMessage, + resolveWebViewUrlFromRuntimeConfig, +} = webViewBridge; + +const runtimeConfig = { + sourceQuery: { + clientType: 'mini_program', + clientRuntime: 'wechat_mini_program', + }, + webViewEntryUrl: 'https://www.genarrative.world', +}; + +describe('mini program web-view launch target', () => { + test('opens the H5 public work detail when launch query carries work params', () => { + expect( + appendLaunchTargetToEntryUrl('https://www.genarrative.world?foo=bar', { + targetPath: '/works/detail', + work: 'BB-12345678', + }), + ).toBe( + 'https://www.genarrative.world/works/detail?foo=bar&work=BB-12345678', + ); + + const webViewUrl = resolveWebViewUrlFromRuntimeConfig( + null, + { + targetPath: '/works/detail', + work: 'BB-12345678', + }, + runtimeConfig, + ); + const url = new URL(webViewUrl); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('BB-12345678'); + expect(url.searchParams.get('clientRuntime')).toBe('wechat_mini_program'); + }); + + test('ignores unsupported launch target paths', () => { + const webViewUrl = resolveWebViewUrlFromRuntimeConfig( + null, + { + targetPath: '/admin', + work: 'BB-12345678', + }, + runtimeConfig, + ); + const url = new URL(webViewUrl); + expect(url.pathname).toBe('/'); + expect(url.searchParams.get('work')).toBeNull(); + }); + + test('keeps public work params in native mini program share paths', () => { + const sharePath = buildWebViewSharePath({ + targetPath: '/works/detail', + work: 'BB-12345678', + }); + const url = new URL(sharePath, 'https://mini.test'); + + expect(url.pathname).toBe('/pages/web-view/index'); + expect(url.searchParams.get('targetPath')).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('BB-12345678'); + expect( + buildWebViewShareTimelineQuery({ + targetPath: '/works/detail', + work: 'BB-12345678', + }), + ).toBe('targetPath=%2Fworks%2Fdetail&work=BB-12345678'); + }); + + test('reads the latest H5 recommended work share target from web-view messages', () => { + expect( + resolveShareTargetFromWebViewMessage({ + data: [ + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'PZ-0001', + title: '旧作品', + }, + }, + }, + { + data: { + type: 'genarrative:share-target', + payload: { + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }, + }, + }, + ], + }), + ).toEqual({ + targetPath: '/works/detail', + work: 'BB-12345678', + title: '汪汪声浪', + }); + }); +}); diff --git a/package.json b/package.json index b9055ebf..de61e0f3 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev:api-server": "node scripts/dev.mjs api-server", "dev:web": "node scripts/dev.mjs web", "dev:admin-web": "node scripts/dev.mjs admin-web", + "server-manager:panel": "cargo run -p server-manager-panel --manifest-path server-rs/Cargo.toml", "dev:spacetime:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "otel:debug": "node scripts/run-otelcol.mjs debug", "otel:rider": "node scripts/run-otelcol.mjs rider", @@ -54,6 +55,7 @@ "container:ps": "node scripts/container-compose.mjs ps", "container:config": "node scripts/container-compose.mjs config", "container:k6": "node scripts/container-compose.mjs k6", + "container:worker-smoke": "node scripts/container-worker-smoke.mjs", "check": "npm run lint && npm run test && npm run build && npm run check:content", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", diff --git a/packages/shared/src/contracts/externalGeneration.ts b/packages/shared/src/contracts/externalGeneration.ts new file mode 100644 index 00000000..8fe24f6f --- /dev/null +++ b/packages/shared/src/contracts/externalGeneration.ts @@ -0,0 +1,29 @@ +export type ExternalGenerationJobStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed'; + +export interface ExternalGenerationQueueOverview { + pendingCount: number; + runningCount: number; + updatedAtMicros: number; +} + +export interface ExternalGenerationQueueOverviewResponse { + overview: ExternalGenerationQueueOverview; +} + +export interface ExternalGenerationJobStatusRecord { + operationId: string; + status: ExternalGenerationJobStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; + updatedAtMicros: number; +} + +export interface ExternalGenerationJobStatusResponse { + job: ExternalGenerationJobStatusRecord; +} diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 9379baa0..43ab0146 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge'; export type JumpHopStylePreset = @@ -206,6 +208,7 @@ export interface JumpHopActionResponse { actionType: JumpHopActionType; session: JumpHopSessionSnapshotResponse; work: JumpHopWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface JumpHopWorkSummaryResponse { diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 501b8cc4..bf0f92ee 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -1,4 +1,5 @@ import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession'; +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; export type PuzzleAgentSuggestedActionType = | 'request_summary' @@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord { phaseDetail: string; progress: number; error?: string | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export type PuzzleAgentActionRequest = diff --git a/packages/shared/src/contracts/puzzleClear.ts b/packages/shared/src/contracts/puzzleClear.ts index 728c93f9..e5e6845f 100644 --- a/packages/shared/src/contracts/puzzleClear.ts +++ b/packages/shared/src/contracts/puzzleClear.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed'; export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3'; @@ -109,6 +111,7 @@ export interface PuzzleClearActionResponse { actionType: PuzzleClearActionType; session: PuzzleClearSessionSnapshotResponse; work: PuzzleClearWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface PuzzleClearWorkSummaryResponse { diff --git a/packages/shared/src/contracts/woodenFish.ts b/packages/shared/src/contracts/woodenFish.ts index 040866f8..033e7a6f 100644 --- a/packages/shared/src/contracts/woodenFish.ts +++ b/packages/shared/src/contracts/woodenFish.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type WoodenFishGenerationStatus = | 'draft' | 'generating' @@ -104,6 +106,7 @@ export interface WoodenFishActionResponse { actionType: WoodenFishActionType; session: WoodenFishSessionSnapshotResponse; work: WoodenFishWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface WoodenFishWorkSummaryResponse { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cfffc0a4..cc84c6f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; export * from './contracts/edutainmentBabyDrawing'; export * from './contracts/edutainmentBabyObject'; +export * from './contracts/externalGeneration'; export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 6a8537f0..89ce254e 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -23,6 +23,46 @@ const checks = [ includes: 'genarrative-health-patrol.timer', reason: 'Server-Provision 必须安装并启用健康巡检 timer。', }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'genarrative-external-generation-controller.service', + reason: 'Server-Provision 必须安装并启用外部生成 worker controller。', + }, + { + file: 'scripts/jenkins-server-provision.sh', + includes: 'genarrative-external-generation-worker@1.service', + reason: 'Server-Provision 必须启用外部生成保底 worker 实例。', + }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'ensure_default_worker_service', + reason: 'API Deploy 必须在缺少 worker 实例时补启动默认外部生成 worker。', + }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'wait_for_worker_services', + reason: 'API Deploy 必须等待外部生成 worker 实例 active。', + }, + { + file: 'scripts/deploy/production-api-deploy.sh', + includes: 'wait_for_worker_controller_service', + reason: 'API Deploy 必须重启并验活外部生成 worker controller。', + }, + { + file: 'deploy/systemd/genarrative-external-generation-worker@.service', + includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-worker', + reason: '外部生成 worker 模板必须作为独立 worker 进程角色运行。', + }, + { + file: 'deploy/systemd/genarrative-external-generation-controller.service', + includes: 'GENARRATIVE_PROCESS_ROLE=external-generation-controller', + reason: '外部生成 worker controller 必须作为独立进程角色运行。', + }, + { + file: 'scripts/ops/production-health-patrol.mjs', + includes: 'checkActiveWorkerInstances', + reason: '生产健康巡检必须检查至少一个外部生成 worker 实例 active。', + }, { file: 'scripts/build-production-release.sh', includes: 'production-health-patrol.mjs', diff --git a/scripts/check-server-provision-tools.sh b/scripts/check-server-provision-tools.sh index 0401529b..ec643133 100755 --- a/scripts/check-server-provision-tools.sh +++ b/scripts/check-server-provision-tools.sh @@ -37,11 +37,11 @@ chmod +x "${TARGET_BIN_DIR}/otelcol-contrib" cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-cli 2.4.1" +echo "spacetimedb-cli 2.5.0" EOF cat >"${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-standalone" <<'EOF' #!/usr/bin/env bash -echo "spacetimedb-standalone 2.4.1" +echo "spacetimedb-standalone 2.5.0" EOF chmod +x \ "${SPACETIME_ROOT_DIR}/bin/current/spacetimedb-cli" \ @@ -58,7 +58,7 @@ if ! ( OTELCOL_TARGET_BIN="${TARGET_BIN_DIR}/otelcol-contrib" \ OTELCOL_VERSION="0.151.0" \ SPACETIME_ROOT="${SPACETIME_ROOT_DIR}" \ - SPACETIME_EXPECTED_VERSION="2.4.1" \ + SPACETIME_EXPECTED_VERSION="2.5.0" \ "${REPO_ROOT}/scripts/prepare-server-provision-tools.sh" \ >"${OUTPUT_LOG}" 2>&1 ); then diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index a935012a..1f55c1e0 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -475,14 +475,14 @@ function loadBaseSources(baseRef) { function getChangedFiles(baseRef) { const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? ''; - const untrackedOutput = + const untrackedModuleOutput = tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? ''; const untrackedBindingsOutput = tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? ''; return new Set( [ ...diffOutput.split(/\u0000/u), - ...untrackedOutput.split(/\u0000/u), + ...untrackedModuleOutput.split(/\u0000/u), ...untrackedBindingsOutput.split(/\u0000/u), ] .map(normalizePath) diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs new file mode 100644 index 00000000..cf5988b0 --- /dev/null +++ b/scripts/container-worker-smoke.mjs @@ -0,0 +1,839 @@ +import {spawn} from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; + +const [, , rawCommand = 'help', ...rawArgs] = process.argv; + +const projectRoot = process.cwd(); +const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml'); +const smokeDir = path.join('deploy', 'container', 'worker-smoke'); +const envPath = path.join(smokeDir, 'api-server.env'); +const statePath = path.join(smokeDir, 'state.json'); +const localImageDir = path.join(smokeDir, 'image'); +const localImageDockerfilePath = path.join(localImageDir, 'Dockerfile.local'); +const localImageBinaryPath = path.join(localImageDir, 'api-server'); +const localCargoTargetDir = path.join('server-rs', 'target-worker-smoke'); +const localSpacetimeImageDir = path.join(smokeDir, 'spacetimedb-image'); +const localSpacetimeDockerfilePath = path.join(localSpacetimeImageDir, 'Dockerfile.local'); +const localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime'); +const localSpacetimeStandalonePath = path.join( + localSpacetimeImageDir, + 'spacetimedb-standalone', +); +const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke'; +const defaultDatabase = + process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke'; + +const command = rawCommand.trim(); +const supportedCommands = new Set([ + 'help', + 'init', + 'build', + 'up-spacetime', + 'publish', + 'up', + 'enqueue', + 'status', + 'api-update', + 'scale', + 'logs', + 'ps', + 'down', + 'smoke', +]); + +if (!supportedCommands.has(command)) { + printHelp(true); + process.exit(1); +} + +try { + await main(); +} catch (error) { + console.error(`[worker-smoke] ${error.message}`); + process.exit(1); +} + +async function main() { + switch (command) { + case 'help': + printHelp(false); + return; + case 'init': + await ensureStateAndEnv({force: rawArgs.includes('--force')}); + return; + case 'build': + await ensureStateAndEnv(); + await buildRuntimeImages(); + return; + case 'up-spacetime': + await ensureStateAndEnv(); + await ensureSpacetimeImage(); + await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']); + await waitForSpacetime(); + return; + case 'publish': + await ensureStateAndEnv(); + await publishModule(); + return; + case 'up': + await ensureStateAndEnv(); + await upRuntime(); + await waitForApi(); + return; + case 'enqueue': + await ensureStateAndEnv(); + await enqueueSmokeJob(); + return; + case 'status': + await ensureStateAndEnv(); + await printQueueStatus(); + return; + case 'api-update': + await ensureStateAndEnv(); + await apiOnlyUpdate({build: rawArgs.includes('--build')}); + return; + case 'scale': + await ensureStateAndEnv(); + await scaleWorkers(rawArgs[0] ?? '1'); + return; + case 'logs': + await ensureStateAndEnv(); + await dockerCompose(['logs', ...rawArgs]); + return; + case 'ps': + await ensureStateAndEnv(); + await dockerCompose(['ps', ...rawArgs]); + return; + case 'down': + await ensureStateAndEnv({create: false}); + await dockerCompose(['down', ...rawArgs]); + return; + case 'smoke': + await runSmoke(); + return; + default: + throw new Error(`未知命令: ${command}`); + } +} + +async function runSmoke() { + if (rawArgs.includes('--force')) { + await ensureStateAndEnv(); + await dockerComposeCapture(['down', '-v'], {allowFailure: true}); + } + const state = await ensureStateAndEnv({force: rawArgs.includes('--force')}); + await assertSavedPortsAvailableForNewProject(state); + console.log( + `[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`, + ); + await buildRuntimeImages(); + await ensureSpacetimeImage(); + await dockerCompose(['up', '-d', 'spacetimedb', 'otelcol']); + await waitForSpacetime(); + await publishModule(); + await upRuntime(); + await waitForApi(); + await assertWorkersRunning(); + + const beforeWorkerIds = await getContainerIds('external-generation-worker'); + console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`); + + const firstJobId = await enqueueSmokeJob({label: 'before-api-update'}); + await waitForJobConsumed(firstJobId); + + await apiOnlyUpdate({build: false}); + const afterWorkerIds = await getContainerIds('external-generation-worker'); + if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) { + throw new Error( + `api-update 后 worker 容器发生变化: before=${beforeWorkerIds.join(',')} after=${afterWorkerIds.join(',')}`, + ); + } + console.log('[worker-smoke] api-only 更新未重建 worker 容器。'); + + const secondJobId = await enqueueSmokeJob({label: 'after-api-update'}); + await waitForJobConsumed(secondJobId); + await printQueueStatus(); + console.log('[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。'); +} + +async function buildRuntimeImages() { + const imageMode = resolveImageMode(); + if (imageMode === 'local-binary') { + await buildLocalBinaryRuntimeImages(); + return; + } + await dockerCompose(['build', 'api-server', 'external-generation-worker']); +} + +function resolveImageMode() { + if (rawArgs.includes('--local-binary')) { + return 'local-binary'; + } + const envMode = process.env.GENARRATIVE_WORKER_SMOKE_IMAGE_MODE; + if (!envMode || envMode === 'dockerfile') { + return 'dockerfile'; + } + if (envMode === 'local-binary') { + return 'local-binary'; + } + throw new Error( + `GENARRATIVE_WORKER_SMOKE_IMAGE_MODE 仅支持 dockerfile 或 local-binary: ${envMode}`, + ); +} + +async function buildLocalBinaryRuntimeImages() { + const profile = + rawArgs.includes('--release') || + process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release' + ? 'release' + : 'debug'; + const buildArgs = ['build', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml']; + if (profile === 'release') { + buildArgs.push('--release'); + } + const cargoImage = resolveLocalBinaryCargoImage(); + const cargoHome = resolveLocalBinaryCargoHome(); + mkdirSync(cargoHome, {recursive: true}); + + console.log( + `[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`, + ); + await run('docker', [ + 'run', + '--rm', + '-u', + currentUserSpec(), + '-v', + `${projectRoot}:/workspace`, + '-v', + `${cargoHome}:/cargo-home`, + '-w', + '/workspace', + '-e', + 'HOME=/cargo-home', + '-e', + 'CARGO_HOME=/cargo-home', + '-e', + `CARGO_TARGET_DIR=/workspace/${toContainerPath(localCargoTargetDir)}`, + cargoImage, + 'cargo', + '--config', + 'build.rustc-wrapper=""', + '--config', + 'target.x86_64-unknown-linux-gnu.linker="cc"', + '--config', + 'target.x86_64-unknown-linux-gnu.rustflags=[]', + ...buildArgs, + ]); + + const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server'); + if (!existsSync(sourceBinaryPath)) { + throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`); + } + + mkdirSync(localImageDir, {recursive: true}); + copyFileSync(sourceBinaryPath, localImageBinaryPath); + chmodSync(localImageBinaryPath, 0o755); + + const baseImage = await resolveLocalBinaryBaseImage(); + writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8'); + + await run('docker', [ + 'build', + '-f', + localImageDockerfilePath, + '-t', + `${projectName}-api-server`, + '-t', + `${projectName}-external-generation-worker`, + localImageDir, + ]); +} + +function resolveLocalBinaryCargoImage() { + return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm'; +} + +function resolveLocalBinaryCargoHome() { + if (process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME) { + return path.resolve(process.env.GENARRATIVE_WORKER_SMOKE_CARGO_HOME); + } + if (!process.env.HOME) { + throw new Error('未找到 HOME,无法挂载本机 Cargo 缓存。'); + } + return path.join(process.env.HOME, '.cargo'); +} + +function currentUserSpec() { + if (typeof process.getuid === 'function' && typeof process.getgid === 'function') { + return `${process.getuid()}:${process.getgid()}`; + } + return '0:0'; +} + +async function ensureSpacetimeImage() { + if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official') { + return; + } + const imageName = localSpacetimeImageName(); + const existingImage = await runCapture('docker', ['image', 'inspect', imageName], { + allowFailure: true, + quiet: true, + }); + if (existingImage.code === 0 && !rawArgs.includes('--force')) { + return; + } + + const spacetimePath = await resolveSpacetimeBinaryPath(); + if (!spacetimePath) { + throw new Error('未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。'); + } + + mkdirSync(localSpacetimeImageDir, {recursive: true}); + copyFileSync(spacetimePath, localSpacetimeBinaryPath); + chmodSync(localSpacetimeBinaryPath, 0o755); + const standalonePath = path.join(path.dirname(spacetimePath), 'spacetimedb-standalone'); + if (!existsSync(standalonePath)) { + throw new Error(`未找到本机 spacetimedb-standalone: ${standalonePath}`); + } + copyFileSync(standalonePath, localSpacetimeStandalonePath); + chmodSync(localSpacetimeStandalonePath, 0o755); + writeFileSync(localSpacetimeDockerfilePath, buildLocalSpacetimeDockerfile(), 'utf8'); + + console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`); + await run('docker', [ + 'build', + '-f', + localSpacetimeDockerfilePath, + '-t', + imageName, + localSpacetimeImageDir, + ]); +} + +function buildLocalSpacetimeDockerfile() { + return `FROM debian:bookworm-slim +WORKDIR /var/lib/spacetimedb +RUN apt-get update && \\ + apt-get install -y --no-install-recommends ca-certificates libstdc++6 zlib1g && \\ + rm -rf /var/lib/apt/lists/* +COPY spacetime /usr/local/bin/spacetime +COPY spacetimedb-standalone /usr/local/bin/spacetimedb-standalone +RUN chmod 0755 /usr/local/bin/spacetime /usr/local/bin/spacetimedb-standalone +ENTRYPOINT ["spacetime"] +`; +} + +async function resolveSpacetimeBinaryPath() { + if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN) { + return process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN; + } + const versionResult = await runCapture('spacetime', ['--version'], {quiet: true}); + const pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu); + if (pathMatch?.[1]) { + return pathMatch[1].trim(); + } + const whichResult = await runCapture('which', ['spacetime'], {quiet: true}); + return whichResult.stdout.trim(); +} + +async function resolveLocalBinaryBaseImage() { + if (process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE) { + return process.env.GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE; + } + return 'debian:bookworm-slim'; +} + +function buildLocalBinaryDockerfile(baseImage) { + return `FROM ${baseImage} +WORKDIR /srv/genarrative +RUN apt-get update && \\ + apt-get install -y --no-install-recommends ca-certificates curl libssl3 zlib1g libzstd1 && \\ + rm -rf /var/lib/apt/lists/* && \\ + (id -u genarrative >/dev/null 2>&1 || useradd --system --create-home --home-dir /srv/genarrative --shell /usr/sbin/nologin genarrative) +COPY api-server /usr/local/bin/api-server +RUN chmod 0755 /usr/local/bin/api-server && \\ + mkdir -p /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox && \\ + chown -R genarrative:genarrative /srv/genarrative /var/lib/genarrative +USER genarrative +EXPOSE 8082 +ENV GENARRATIVE_ENV=container \\ + GENARRATIVE_API_HOST=0.0.0.0 \\ + GENARRATIVE_API_PORT=8082 \\ + GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox +CMD ["api-server"] +`; +} + +function toContainerPath(localPath) { + return localPath.split(path.sep).join('/'); +} + +async function upRuntime() { + const services = ['api-server', 'external-generation-worker']; + if (rawArgs.includes('--with-nginx')) { + services.push('nginx'); + } + await dockerCompose(['up', '-d', ...services]); +} + +async function ensureStateAndEnv(options = {}) { + const {force = false, create = true} = options; + if (!create && !existsSync(statePath)) { + return defaultState(); + } + mkdirSync(smokeDir, {recursive: true}); + + if (!existsSync(statePath) || force) { + const state = { + database: defaultDatabase, + spacetimePort: await findAvailablePort( + Number(process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_PORT || 19101), + ), + httpPort: await findAvailablePort( + Number(process.env.GENARRATIVE_WORKER_SMOKE_HTTP_PORT || 19080), + ), + otlpGrpcPort: await findAvailablePort( + Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_GRPC_PORT || 15317), + ), + otlpHttpPort: await findAvailablePort( + Number(process.env.GENARRATIVE_WORKER_SMOKE_OTLP_HTTP_PORT || 15318), + ), + createdAt: new Date().toISOString(), + }; + writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + } + + const state = readState(); + if (!existsSync(envPath) || force) { + writeFileSync(envPath, buildSmokeEnv(state), 'utf8'); + } + console.log(`[worker-smoke] env=${envPath}`); + console.log(`[worker-smoke] state=${statePath}`); + console.log(`[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`); + console.log(`[worker-smoke] Nginx=http://127.0.0.1:${state.httpPort}`); + return state; +} + +function buildSmokeEnv(state) { + return `# 本文件由 scripts/container-worker-smoke.mjs 生成,仅用于本机隔离 worker smoke。 +# 不要在这里写真实生产密钥;目录 deploy/container/worker-smoke/ 已被 gitignore。 +GENARRATIVE_ENV=container-worker-smoke +GENARRATIVE_API_HOST=0.0.0.0 +GENARRATIVE_API_PORT=8082 +GENARRATIVE_API_LOG=info,tower_http=info +GENARRATIVE_API_LISTEN_BACKLOG=256 +GENARRATIVE_API_WORKER_THREADS=2 +GENARRATIVE_PROCESS_ROLE=api +GENARRATIVE_EXTERNAL_GENERATION_MODE=queue +GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID= +GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=1 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=500 +GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=60 +GENARRATIVE_API_MAX_CONCURRENT_REQUESTS=64 +GENARRATIVE_API_GALLERY_MAX_CONCURRENT_REQUESTS=32 +GENARRATIVE_API_DETAIL_MAX_CONCURRENT_REQUESTS=16 +GENARRATIVE_API_ADMIN_MAX_CONCURRENT_REQUESTS=8 +GENARRATIVE_TRACKING_OUTBOX_ENABLED=false +GENARRATIVE_TRACKING_OUTBOX_DIR=/var/lib/genarrative/tracking-outbox + +GENARRATIVE_OTEL_ENABLED=false +OTEL_SERVICE_NAME=genarrative-worker-smoke-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4318 +OTEL_RESOURCE_ATTRIBUTES=deployment.environment=worker-smoke,service.namespace=genarrative + +GENARRATIVE_INTERNAL_API_SECRET=worker-smoke-internal-secret +GENARRATIVE_JWT_ISSUER=genarrative-worker-smoke +GENARRATIVE_JWT_SECRET=worker-smoke-jwt-secret +AUTH_REFRESH_COOKIE_SECURE=false +GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true + +GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101 +GENARRATIVE_SPACETIME_DATABASE=${state.database} +GENARRATIVE_SPACETIME_TOKEN= +GENARRATIVE_SPACETIME_POOL_SIZE=2 +GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15 + +GENARRATIVE_LLM_PROVIDER=openai-compatible +GENARRATIVE_LLM_BASE_URL= +GENARRATIVE_LLM_API_KEY= +GENARRATIVE_LLM_MODEL= +VECTOR_ENGINE_BASE_URL= +VECTOR_ENGINE_API_KEY= +ALIYUN_OSS_BUCKET= +ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com +ALIYUN_OSS_ACCESS_KEY_ID= +ALIYUN_OSS_ACCESS_KEY_SECRET= +WECHAT_MINIPROGRAM_MESSAGE_TOKEN= +WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY= +`; +} + +function defaultState() { + return { + database: defaultDatabase, + spacetimePort: 19101, + httpPort: 19080, + otlpGrpcPort: 15317, + otlpHttpPort: 15318, + }; +} + +function readState() { + if (!existsSync(statePath)) { + return defaultState(); + } + return JSON.parse(readFileSync(statePath, 'utf8')); +} + +async function findAvailablePort(startPort) { + for (let port = startPort; port < startPort + 100; port += 1) { + if (await isPortAvailable(port)) { + return port; + } + } + throw new Error(`未找到可用端口: ${startPort}-${startPort + 99}`); +} + +function isPortAvailable(port) { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => resolve(false)); + server.once('listening', () => { + server.close(() => resolve(true)); + }); + server.listen(port, '127.0.0.1'); + }); +} + +async function publishModule() { + const state = readState(); + const serverUrl = spacetimeServerUrl(state); + const publishArgs = [ + 'publish', + state.database, + '--server', + serverUrl, + '--module-path', + 'server-rs/crates/spacetime-module', + '--delete-data=on-conflict', + '--anonymous', + '--yes=all', + '--no-config', + ]; + const buildOptions = process.env.GENARRATIVE_WORKER_SMOKE_STDB_BUILD_OPTIONS; + if (buildOptions) { + publishArgs.push('--build-options', buildOptions); + } + await run('spacetime', publishArgs); +} + +async function enqueueSmokeJob(options = {}) { + if (!rawArgs.includes('--no-worker-check')) { + await assertWorkersRunning(); + } + const state = readState(); + const nowMicros = Date.now() * 1000; + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const jobId = `extgen-smoke-${suffix}`; + const label = options.label || rawArgs[0] || 'manual'; + const input = { + job_id: jobId, + dedupe_key: `worker-smoke:${label}:${suffix}`, + job_kind: 'worker_smoke_unsupported', + owner_user_id: 'worker-smoke-user', + source_module: 'worker-smoke', + source_entity_id: `worker-smoke-entity-${suffix}`, + request_label: `worker-smoke ${label}`, + request_payload_json: JSON.stringify({label, suffix}), + max_attempts: 1, + available_at_micros: nowMicros, + created_at_micros: nowMicros, + }; + + await run('spacetime', [ + 'call', + '--server', + spacetimeServerUrl(state), + '--anonymous', + '--yes', + '--no-config', + state.database, + 'enqueue_external_generation_job_and_return', + JSON.stringify(input), + ]); + console.log(`[worker-smoke] 已入队测试 job: ${jobId}`); + return jobId; +} + +async function printQueueStatus() { + console.log('[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:'); + await printServiceLogs('external-generation-worker', 120); +} + +async function waitForJobConsumed(jobId) { + const deadline = Date.now() + 60_000; + let lastOutput = ''; + while (Date.now() < deadline) { + const result = await dockerComposeCapture( + ['logs', '--no-color', 'external-generation-worker'], + {allowFailure: true, quiet: true}, + ); + lastOutput = `${result.stdout}\n${result.stderr}`; + if (lastOutput.includes(jobId) && lastOutput.includes('暂不支持的任务类型')) { + console.log(`[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`); + return; + } + await sleep(1000); + } + await printServiceLogs('external-generation-worker', 120); + throw new Error(`等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`); +} + +async function assertSavedPortsAvailableForNewProject(state) { + const existingContainers = await getProjectContainerIds(); + if (existingContainers.length > 0) { + return; + } + const ports = [ + ['SpacetimeDB', state.spacetimePort], + ['Nginx', state.httpPort], + ['OTLP gRPC', state.otlpGrpcPort], + ['OTLP HTTP', state.otlpHttpPort], + ]; + for (const [label, port] of ports) { + if (!(await isPortAvailable(port))) { + throw new Error( + `${label} 端口 ${port} 已被占用;可执行 npm run container:worker-smoke -- smoke --force 重新分配隔离端口。`, + ); + } + } +} + +async function getProjectContainerIds() { + const result = await dockerComposeCapture(['ps', '-q'], { + allowFailure: true, + quiet: true, + }); + if (result.code !== 0) { + return []; + } + return result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); +} + +async function assertWorkersRunning() { + const result = await dockerComposeCapture( + ['ps', '--status', 'running', '-q', 'external-generation-worker'], + {allowFailure: true, quiet: true}, + ); + const workerIds = result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean); + if (result.code === 0 && workerIds.length > 0) { + return; + } + await printServiceLogs('external-generation-worker', 80); + throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。'); +} + +async function printServiceLogs(service, tail = 80) { + await dockerComposeCapture(['logs', '--tail', String(tail), service], { + allowFailure: true, + }); +} + +async function waitForSpacetime() { + const state = readState(); + const url = `${spacetimeServerUrl(state)}/v1/ping`; + await waitForHttp(url, 'SpacetimeDB'); +} + +async function waitForApi() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const result = await dockerComposeCapture( + ['exec', '-T', 'api-server', 'curl', '-fsS', 'http://127.0.0.1:8082/healthz'], + {allowFailure: true, quiet: true}, + ); + if (result.code === 0) { + console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz'); + return; + } + await sleep(2000); + } + throw new Error('api-server 等待超时: api-server:8082/healthz'); +} + +async function waitForHttp(url, label) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const result = await runCapture('curl', ['-fsS', '--max-time', '3', url], { + allowFailure: true, + }); + if (result.code === 0) { + console.log(`[worker-smoke] ${label} 已就绪: ${url}`); + return; + } + await sleep(2000); + } + throw new Error(`${label} 等待超时: ${url}`); +} + +async function apiOnlyUpdate({build}) { + const beforeWorkerIds = await getContainerIds('external-generation-worker'); + const args = ['up', '-d', '--no-deps', '--force-recreate']; + if (build) { + args.push('--build'); + } + args.push('api-server'); + await dockerCompose(args); + await waitForApi(); + const afterWorkerIds = await getContainerIds('external-generation-worker'); + if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) { + throw new Error('API-only 更新不应重建 external-generation-worker 容器'); + } + console.log('[worker-smoke] API-only 更新完成,worker 容器保持不变。'); +} + +async function scaleWorkers(rawCount) { + const count = Number.parseInt(rawCount, 10); + if (!Number.isInteger(count) || count < 0 || count > 16) { + throw new Error(`worker 数量必须是 0-16 的整数: ${rawCount}`); + } + await dockerCompose([ + 'up', + '-d', + '--scale', + `external-generation-worker=${count}`, + 'external-generation-worker', + ]); +} + +async function getContainerIds(service) { + const result = await dockerComposeCapture(['ps', '-q', service]); + return result.stdout + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .sort(); +} + +async function dockerCompose(args) { + await run('docker', composeArgs(args), {env: composeEnv()}); +} + +async function dockerComposeCapture(args, options = {}) { + return runCapture('docker', composeArgs(args), { + env: composeEnv(), + ...options, + }); +} + +function composeArgs(args) { + return ['compose', '-p', projectName, '-f', composeFile, ...args]; +} + +function composeEnv() { + const state = readState(); + return { + ...process.env, + GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env', + GENARRATIVE_CONTAINER_SPACETIME_IMAGE: + process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || localSpacetimeImageName(), + GENARRATIVE_CONTAINER_SPACETIME_PORT: String(state.spacetimePort), + GENARRATIVE_CONTAINER_HTTP_PORT: String(state.httpPort), + GENARRATIVE_CONTAINER_OTLP_GRPC_PORT: String(state.otlpGrpcPort), + GENARRATIVE_CONTAINER_OTLP_HTTP_PORT: String(state.otlpHttpPort), + }; +} + +function localSpacetimeImageName() { + return `${projectName}-spacetimedb:2.5.0`; +} + +function spacetimeServerUrl(state) { + return `http://127.0.0.1:${state.spacetimePort}`; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function run(commandName, args, options = {}) { + const result = await runCapture(commandName, args, options); + if (result.code !== 0 && !options.allowFailure) { + throw new Error(`${commandName} ${args.join(' ')} 失败,exit=${result.code}`); + } + return result; +} + +function runCapture(commandName, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(commandName, args, { + cwd: projectRoot, + env: options.env ?? process.env, + shell: false, + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk) => { + const text = chunk.toString(); + stdout += text; + if (!options.quiet) { + process.stdout.write(text); + } + }); + child.stderr?.on('data', (chunk) => { + const text = chunk.toString(); + stderr += text; + if (!options.quiet) { + process.stderr.write(text); + } + }); + child.on('error', reject); + child.on('exit', (code, signal) => { + if (signal) { + reject(new Error(`${commandName} 被信号终止: ${signal}`)); + return; + } + resolve({code: code ?? 0, stdout, stderr}); + }); + }); +} + +function printHelp(isError) { + const output = isError ? console.error : console.log; + output(`Usage: npm run container:worker-smoke -- + +Commands: + init [--force] 生成隔离 env 与端口 state + build [--local-binary] [--release] + 构建 api-server / worker 镜像;--local-binary 让容器内 Cargo 复用本机缓存 + up-spacetime 启动隔离 SpacetimeDB 与 otelcol + publish 向隔离 SpacetimeDB 发布 spacetime-module + up [--with-nginx] 启动 api-server / worker;需要 Nginx 时显式加 --with-nginx + enqueue [label] [--no-worker-check] + 写入一个 unsupported 测试 job,验证 worker claim/fail + status 查看最近 worker 日志;external_generation_job 是 private table + api-update [--build] 仅重建/重启 api-server,不触碰 worker + scale 调整 external-generation-worker 实例数 + ps 查看隔离 compose 状态 + logs [service] 查看隔离 compose 日志 + down [-v] 停止隔离 compose,-v 会清理数据卷 + smoke [--force] [--local-binary] [--release] + 一键执行 build -> publish -> up -> enqueue -> api-update -> enqueue +`); +} diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 14029c52..e215b3e9 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -5,10 +5,11 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] + ./scripts/deploy/production-api-deploy.sh --source-dir build/ [--version ] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--worker-service-pattern 'genarrative-external-generation-worker@*.service'] [--no-worker-services] [--worker-controller-service genarrative-external-generation-controller.service] [--no-worker-controller] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101] 说明: 进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。 + 默认同时重启外部生成 worker controller 和已加载的 worker 实例;未启用 worker 单元时会自动跳过。 若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。 失败时保留维护模式。 EOF @@ -223,12 +224,144 @@ ensure_runtime_env_and_dirs() { fi } +list_worker_services() { + local pattern="$1" + + if [[ -z "${pattern}" ]]; then + return 0 + fi + + systemctl list-units --all --plain --no-legend "${pattern}" 2>/dev/null | awk '{print $1}' | sort -u +} + +ensure_default_worker_service() { + local pattern="$1" + local default_service="genarrative-external-generation-worker@1.service" + local template_service="genarrative-external-generation-worker@.service" + local services=() + + if [[ -z "${pattern}" ]]; then + return 0 + fi + + if [[ "${pattern}" != "genarrative-external-generation-worker@*.service" ]]; then + return 0 + fi + + if ! systemctl cat "${template_service}" >/dev/null 2>&1; then + echo "[production-api-deploy] 缺少外部生成 worker systemd 模板: ${template_service}" >&2 + return 1 + fi + + mapfile -t services < <(list_worker_services "${pattern}") + if [[ "${#services[@]}" -gt 0 ]]; then + return 0 + fi + + echo "[production-api-deploy] 未发现外部生成 worker 实例,启用并启动默认实例: ${default_service}" + systemctl enable --now "${default_service}" +} + +restart_worker_services() { + local pattern="$1" + local services=() + + if [[ -z "${pattern}" ]]; then + echo "[production-api-deploy] 跳过外部生成 worker 重启。" + return 0 + fi + + ensure_default_worker_service "${pattern}" + mapfile -t services < <(list_worker_services "${pattern}") + if [[ "${#services[@]}" -eq 0 ]]; then + echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2 + return 1 + fi + + echo "[production-api-deploy] 重启外部生成 worker: ${services[*]}" + systemctl restart "${services[@]}" +} + +wait_for_worker_services() { + local pattern="$1" + local services=() + local all_active + + if [[ -z "${pattern}" ]]; then + return 0 + fi + + mapfile -t services < <(list_worker_services "${pattern}") + if [[ "${#services[@]}" -eq 0 ]]; then + echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2 + return 1 + fi + + echo "[production-api-deploy] 等待外部生成 worker active: ${services[*]}" + for _ in {1..30}; do + all_active=1 + for service in "${services[@]}"; do + if ! systemctl is-active --quiet "${service}"; then + all_active=0 + break + fi + done + if [[ "${all_active}" -eq 1 ]]; then + return 0 + fi + sleep 2 + done + + systemctl --no-pager --full status "${services[@]}" || true + echo "[production-api-deploy] 外部生成 worker 未在超时时间内进入 active,发布失败。" >&2 + return 1 +} + +ensure_worker_controller_service() { + local service="$1" + + if [[ -z "${service}" ]]; then + return 0 + fi + + if ! systemctl cat "${service}" >/dev/null 2>&1; then + echo "[production-api-deploy] 缺少外部生成 worker controller systemd 单元: ${service}" >&2 + return 1 + fi + + echo "[production-api-deploy] 启用并重启外部生成 worker controller: ${service}" + systemctl enable "${service}" + systemctl restart "${service}" +} + +wait_for_worker_controller_service() { + local service="$1" + + if [[ -z "${service}" ]]; then + return 0 + fi + + echo "[production-api-deploy] 等待外部生成 worker controller active: ${service}" + for _ in {1..30}; do + if systemctl is-active --quiet "${service}"; then + return 0 + fi + sleep 2 + done + + systemctl --no-pager --full status "${service}" || true + echo "[production-api-deploy] 外部生成 worker controller 未在超时时间内进入 active,发布失败。" >&2 + return 1 +} + SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" SOURCE_DIR="" VERSION="" RELEASE_ROOT="/opt/genarrative/releases" CURRENT_LINK="/opt/genarrative/current" SERVICE_NAME="genarrative-api.service" +WORKER_SERVICE_PATTERN="genarrative-external-generation-worker@*.service" +WORKER_CONTROLLER_SERVICE="genarrative-external-generation-controller.service" HEALTH_URL="http://127.0.0.1:8082/readyz" API_ENV_FILE="/etc/genarrative/api-server.env" DATABASE="" @@ -261,6 +394,22 @@ while [[ $# -gt 0 ]]; do SERVICE_NAME="${2:?缺少 --service 的值}" shift 2 ;; + --worker-service-pattern) + WORKER_SERVICE_PATTERN="${2:?缺少 --worker-service-pattern 的值}" + shift 2 + ;; + --no-worker-services) + WORKER_SERVICE_PATTERN="" + shift + ;; + --worker-controller-service) + WORKER_CONTROLLER_SERVICE="${2:?缺少 --worker-controller-service 的值}" + shift 2 + ;; + --no-worker-controller) + WORKER_CONTROLLER_SERVICE="" + shift + ;; --health-url) HEALTH_URL="${2:?缺少 --health-url 的值}" shift 2 @@ -383,6 +532,10 @@ ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}" echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}" +restart_worker_services "${WORKER_SERVICE_PATTERN}" +wait_for_worker_services "${WORKER_SERVICE_PATTERN}" +ensure_worker_controller_service "${WORKER_CONTROLLER_SERVICE}" +wait_for_worker_controller_service "${WORKER_CONTROLLER_SERVICE}" echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}" for _ in {1..30}; do diff --git a/scripts/dev.mjs b/scripts/dev.mjs index dd737e08..a76c20ee 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -37,6 +37,7 @@ const manifestPath = resolve(serverRsDir, 'Cargo.toml'); const modulePath = resolve(serverRsDir, 'crates/spacetime-module'); const viteCliPath = resolve(repoRoot, 'scripts/vite-cli.mjs'); const adminWebDir = resolve(repoRoot, 'apps/admin-web'); +const LOCAL_DEV_RUSTC_WRAPPER_BYPASS = process.platform === 'win32' ? 'rustc' : '/usr/bin/env'; const SERVICE_NAMES = ['spacetime', 'api-server', 'web', 'admin-web']; const SERVICE_ALIASES = new Map([ @@ -399,6 +400,39 @@ function requireCommand(command) { } } +function isSccacheRustcWrapper(value) { + const wrapper = String(value ?? '').trim(); + if (!wrapper) { + return false; + } + + const command = wrapper.split(/[\\/]/).pop()?.toLowerCase(); + return command === 'sccache' || command === 'sccache.exe'; +} + +function buildLocalRustProcessEnv(env, options = {}) { + const mergedEnv = {...env}; + const wrappers = [ + String(mergedEnv.RUSTC_WRAPPER ?? '').trim(), + String(mergedEnv.CARGO_BUILD_RUSTC_WRAPPER ?? '').trim(), + ].filter(Boolean); + const customWrapper = wrappers.find((wrapper) => !isSccacheRustcWrapper(wrapper)); + if (customWrapper) { + mergedEnv.RUSTC_WRAPPER = customWrapper; + mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = customWrapper; + return mergedEnv; + } + + mergedEnv.RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; + mergedEnv.CARGO_BUILD_RUSTC_WRAPPER = LOCAL_DEV_RUSTC_WRAPPER_BYPASS; + if (options.log !== false) { + console.warn( + '[dev:rust] 本地 dev 构建绕过项目 sccache wrapper,避免缓存进程异常阻断启动。', + ); + } + return mergedEnv; +} + function readWorkspaceSpacetimeVersion() { const manifestText = readFileSync(manifestPath, 'utf8'); const match = /^spacetimedb\s*=\s*(?:"([^"]+)"|\{[^}]*version\s*=\s*"([^"]+)")/mu.exec( @@ -451,7 +485,7 @@ function assertSpacetimeToolVersionMatchesWorkspace({ [ `本机 spacetime CLI/standalone 版本 ${toolVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`, '版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。', - `请执行 spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion} 后重新运行本命令。`, + `请先直接升级并切换到锁定版本: spacetime version install ${workspaceVersion} && spacetime version use ${workspaceVersion},然后重新运行本命令。`, ].join(''), ); } @@ -479,9 +513,11 @@ function assertReusableSpacetimeProcessVersionMatchesWorkspace({ [ `正在运行的本地 SpacetimeDB standalone 版本 ${recordedVersion} 与 server-rs 锁定的 SpacetimeDB ${workspaceVersion} 不一致。`, '版本错位会导致 procedure 返回值 BSATN 反序列化失败,前端表现为 SpacetimeDB procedure 调用超时。', - '请停止当前 SpacetimeDB 进程,执行 spacetime version use ', + '请停止当前 SpacetimeDB 进程,先直接升级并切换到锁定版本: spacetime version install ', workspaceVersion, - ' 后重新运行 npm run dev:spacetime。', + ' && spacetime version use ', + workspaceVersion, + ',然后重新运行 npm run dev:spacetime。', ].join(''), ); } @@ -776,7 +812,7 @@ class DevRunner { this.writeDevStackState(); } - async prepareLinuxPortRange(command) { + async prepareLinuxPortRange() { if (process.platform !== 'linux') { return; } @@ -1228,7 +1264,7 @@ class DevRunner { } async publishSpacetimeModule() { - const env = {...this.baseEnv}; + const env = buildLocalRustProcessEnv(this.baseEnv); this.prepareMigrationBootstrapSecret(env); const args = buildSpacetimePublishArgs({ @@ -1291,7 +1327,7 @@ class DevRunner { await this.ensureApiServerSpacetimeToken(); const mergedEnv = buildApiServerProcessEnv({ - baseEnv: this.baseEnv, + baseEnv: buildLocalRustProcessEnv(this.baseEnv), options: this.options, state: this.state, }); @@ -2124,19 +2160,20 @@ function buildApiServerProcessEnv({baseEnv, options, state}) { } export { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, - isSpacetimePublishPermissionError, + DevRunner, isDirectModuleExecution, + isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, }; diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index cf3640b5..3cd31d33 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -5,19 +5,20 @@ import {join} from 'node:path'; import {afterEach, describe, expect, test, vi} from 'vitest'; import { - DevRunner, assertReusableSpacetimeProcessVersionMatchesWorkspace, assertSpacetimeToolVersionMatchesWorkspace, buildApiServerProcessEnv, buildDevStackSnapshot, + buildLocalRustProcessEnv, buildSpacetimePublishArgs, createDevServerSpawnOptions, createWatchConfigs, + DevRunner, isDirectModuleExecution, isSpacetimePublishPermissionError, normalizeCargoVersionRequirement, - parseSpacetimeToolVersion, parseArgs, + parseSpacetimeToolVersion, resolveDevStackStatePath, shouldAcceptWatchEvent, } from './dev.mjs'; @@ -185,6 +186,35 @@ describe('dev scheduler api-server env', () => { }); }); +describe('dev scheduler Rust build env', () => { + test('local dev Rust env bypasses project sccache wrapper', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: '/usr/bin/sccache', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).not.toBe('/usr/bin/sccache'); + expect(env.RUSTC_WRAPPER).not.toBe('sccache'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe(env.RUSTC_WRAPPER); + }); + + test('local dev Rust env keeps healthy custom wrapper untouched', () => { + const env = buildLocalRustProcessEnv( + { + RUSTC_WRAPPER: 'custom-wrapper', + CARGO_BUILD_RUSTC_WRAPPER: 'sccache', + }, + {log: false}, + ); + + expect(env.RUSTC_WRAPPER).toBe('custom-wrapper'); + expect(env.CARGO_BUILD_RUSTC_WRAPPER).toBe('custom-wrapper'); + }); +}); + describe('dev scheduler stack state file', () => { test('状态文件路径固定在根目录 .app/dev-stack.json', () => { expect(resolveDevStackStatePath('C:\\repo\\Genarrative')).toBe( @@ -404,24 +434,24 @@ describe('dev scheduler watch routing', () => { describe('dev scheduler spacetime refresh', () => { test('解析 Cargo 精确版本要求时用于 CLI 校验的版本号不带等号', () => { - expect(normalizeCargoVersionRequirement('=2.4.1')).toBe('2.4.1'); - expect(normalizeCargoVersionRequirement('2.4.1')).toBe('2.4.1'); + expect(normalizeCargoVersionRequirement('=2.5.0')).toBe('2.5.0'); + expect(normalizeCargoVersionRequirement('2.5.0')).toBe('2.5.0'); }); test('解析 spacetime --version 输出里的 tool version', () => { const version = parseSpacetimeToolVersion(` -A new version of SpacetimeDB is available: v2.4.1 (current: v2.4.0) -spacetimedb tool version 2.4.1; spacetimedb-lib version 2.4.1; +A new version of SpacetimeDB is available: v2.5.0 (current: v2.4.1) +spacetimedb tool version 2.5.0; spacetimedb-lib version 2.5.0; `); - expect(version).toBe('2.4.1'); + expect(version).toBe('2.5.0'); }); test('本机 spacetime 版本和 workspace 锁定版本不一致时直接报清楚', () => { expect(() => assertSpacetimeToolVersionMatchesWorkspace({ toolVersion: '2.1.0', - workspaceVersion: '2.4.1', + workspaceVersion: '2.5.0', }), ).toThrow('procedure 返回值 BSATN 反序列化失败'); }); diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 9f399e84..279a4dac 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -4,6 +4,8 @@ set -euo pipefail PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" SPACETIME_BIN_SOURCE="${SPACETIME_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/spacetime/spacetime}" OTELCOL_BIN_SOURCE="${OTELCOL_BIN_SOURCE:-${PROVISION_TOOLS_DIR}/otelcol-contrib}" +WORKER_ENV_FILE="${WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}" +CONTROLLER_ENV_FILE="${CONTROLLER_ENV_FILE:-/etc/genarrative/external-generation-controller.env}" GENARRATIVE_OPENSSL_VERSION="${GENARRATIVE_OPENSSL_VERSION:-3.2.0}" GENARRATIVE_OPENSSL_PREFIX="${GENARRATIVE_OPENSSL_PREFIX:-/opt/genarrative/openssl-3.2.0}" GENARRATIVE_OPENSSL_SOURCE_URL="${GENARRATIVE_OPENSSL_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${GENARRATIVE_OPENSSL_VERSION}/openssl-${GENARRATIVE_OPENSSL_VERSION}.tar.gz}" @@ -242,6 +244,47 @@ sync_otelcol_install() { fi } +ensure_otelcol_runtime() { + if [[ "${ENABLE_OTELCOL:-true}" != "true" ]]; then + return + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ ensure system user/group otelcol" + echo "+ install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol" + echo "+ install -d -m 0755 -o root -g root /etc/otelcol" + echo "+ install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative" + echo "+ install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml" + return + fi + + if ! getent group otelcol >/dev/null 2>&1; then + groupadd --system otelcol + fi + if ! id otelcol >/dev/null 2>&1; then + useradd --system --gid otelcol --home-dir /var/lib/otelcol --shell /usr/sbin/nologin otelcol + fi + + install -d -m 0755 -o otelcol -g otelcol /var/lib/otelcol + install -d -m 0755 -o root -g root /etc/otelcol + install -d -m 0755 -o genarrative -g genarrative /var/log/genarrative + install -m 0644 deploy/otelcol/genarrative-debug.yaml /etc/otelcol/genarrative-debug.yaml + chown root:root /etc/otelcol/genarrative-debug.yaml +} + +stamp_database_backup_timer_now() { + if [[ "${DRY_RUN}" == "true" ]]; then + echo "+ install -d -m 0755 /var/lib/systemd/timers" + echo "+ touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer" + return + fi + + install -d -m 0755 /var/lib/systemd/timers + # 避免 provision 在当天 03:20 之后启动 timer 时因 Persistent=true 立刻补跑冷备份、 + # 进而在初始化/发布窗口中意外停止 spacetimedb.service。 + touch /var/lib/systemd/timers/stamp-genarrative-database-backup.timer +} + sync_spacetime_install() { local root_dir="$1" local target_bin_dir="${root_dir}/bin/current" @@ -458,6 +501,7 @@ ensure_spacetime_owner_client_token() { echo "[server-provision] 已生成 SpacetimeDB client identity 并写入 GENARRATIVE_SPACETIME_TOKEN: ${identity_preview}..." fi + # 中文注释:这里是 provision 内部为 spacetimedb 运行用户隔离 CLI 登录态的受控用法,不作为人工 spacetime 命令示例。 if ! login_output="$(runuser -u spacetimedb -- "${cli_path}" --root-dir "${SPACETIME_ROOT}" login --token "${token}" 2>&1)"; then echo "[server-provision] 使用 GENARRATIVE_SPACETIME_TOKEN 登录 SpacetimeDB CLI 失败。" >&2 printf "%s\\n" "${login_output}" | sed -E "s/[A-Za-z0-9_.=-]{24,}/[REDACTED]/g" >&2 @@ -536,6 +580,14 @@ render_api_env_example() { deploy/env/api-server.env.example } +render_external_generation_worker_env_example() { + cat deploy/env/external-generation-worker.env.example +} + +render_external_generation_controller_env_example() { + cat deploy/env/external-generation-controller.env.example +} + render_otelcol_service() { cat deploy/systemd/otelcol-contrib.service } @@ -722,6 +774,30 @@ render_api_service() { deploy/systemd/genarrative-api.service } +render_external_generation_worker_service() { + local current_escaped api_env_escaped worker_env_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + worker_env_escaped="$(escape_sed_replacement "${WORKER_ENV_FILE}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \ + -e "s|/etc/genarrative/external-generation-worker.env|${worker_env_escaped}|g" \ + deploy/systemd/genarrative-external-generation-worker@.service +} + +render_external_generation_controller_service() { + local current_escaped api_env_escaped controller_env_escaped + current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" + api_env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")" + controller_env_escaped="$(escape_sed_replacement "${CONTROLLER_ENV_FILE}")" + sed \ + -e "s|/opt/genarrative/current|${current_escaped}|g" \ + -e "s|/etc/genarrative/api-server.env|${api_env_escaped}|g" \ + -e "s|/etc/genarrative/external-generation-controller.env|${controller_env_escaped}|g" \ + deploy/systemd/genarrative-external-generation-controller.service +} + render_database_backup_service() { local current_escaped env_escaped current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")" @@ -742,6 +818,8 @@ render_health_patrol_service() { require_path deploy/systemd/spacetimedb.service require_path deploy/systemd/genarrative-api.service +require_path deploy/systemd/genarrative-external-generation-worker@.service +require_path deploy/systemd/genarrative-external-generation-controller.service require_path deploy/systemd/genarrative-database-backup.service require_path deploy/systemd/genarrative-database-backup.timer require_path deploy/systemd/genarrative-health-patrol.service @@ -752,6 +830,8 @@ require_path deploy/nginx/genarrative.conf require_path deploy/nginx/genarrative-dev-http.conf require_path deploy/nginx/snippets/genarrative-maintenance.conf require_path deploy/env/api-server.env.example +require_path deploy/env/external-generation-worker.env.example +require_path deploy/env/external-generation-controller.env.example require_path scripts/deploy/maintenance-on.sh require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh @@ -795,19 +875,25 @@ sync_spacetime_install "${SPACETIME_ROOT}" spacetimedb_service="$(mktemp)" api_service="$(mktemp)" +external_generation_worker_service="$(mktemp)" +external_generation_controller_service="$(mktemp)" database_backup_service="$(mktemp)" health_patrol_service="$(mktemp)" render_spacetimedb_service >"${spacetimedb_service}" render_api_service >"${api_service}" +render_external_generation_worker_service >"${external_generation_worker_service}" +render_external_generation_controller_service >"${external_generation_controller_service}" render_database_backup_service >"${database_backup_service}" render_health_patrol_service >"${health_patrol_service}" install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644 install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644 +install_file "${external_generation_worker_service}" /etc/systemd/system/genarrative-external-generation-worker@.service 0644 +install_file "${external_generation_controller_service}" /etc/systemd/system/genarrative-external-generation-controller.service 0644 install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644 install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644 install_file "${health_patrol_service}" /etc/systemd/system/genarrative-health-patrol.service 0644 install_file deploy/systemd/genarrative-health-patrol.timer /etc/systemd/system/genarrative-health-patrol.timer 0644 -rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}" "${health_patrol_service}" +rm -f "${spacetimedb_service}" "${api_service}" "${external_generation_worker_service}" "${external_generation_controller_service}" "${database_backup_service}" "${health_patrol_service}" if [[ ! -f "${API_ENV_FILE}" ]]; then echo "+ create ${API_ENV_FILE} from example" @@ -821,8 +907,31 @@ else fi ensure_api_runtime_env_defaults +if [[ ! -f "${WORKER_ENV_FILE}" ]]; then + echo "+ create ${WORKER_ENV_FILE} from example" + if [[ "${DRY_RUN}" != "true" ]]; then + render_external_generation_worker_env_example >"${WORKER_ENV_FILE}" + chmod 0600 "${WORKER_ENV_FILE}" + chown root:root "${WORKER_ENV_FILE}" + fi +else + echo "[server-provision] 已存在 worker 环境文件,保留不覆盖: ${WORKER_ENV_FILE}" +fi + +if [[ ! -f "${CONTROLLER_ENV_FILE}" ]]; then + echo "+ create ${CONTROLLER_ENV_FILE} from example" + if [[ "${DRY_RUN}" != "true" ]]; then + render_external_generation_controller_env_example >"${CONTROLLER_ENV_FILE}" + chmod 0600 "${CONTROLLER_ENV_FILE}" + chown root:root "${CONTROLLER_ENV_FILE}" + fi +else + echo "[server-provision] 已存在 controller 环境文件,保留不覆盖: ${CONTROLLER_ENV_FILE}" +fi + if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then sync_otelcol_install + ensure_otelcol_runtime otelcol_service="$(mktemp)" render_otelcol_service >"${otelcol_service}" install_file "${otelcol_service}" /etc/systemd/system/otelcol-contrib.service 0644 @@ -842,7 +951,9 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then run_cmd systemctl enable otelcol-contrib.service fi - run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-health-patrol.timer + stamp_database_backup_timer_now + run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer genarrative-external-generation-worker@1.service genarrative-external-generation-controller.service genarrative-health-patrol.timer + run_cmd systemctl start genarrative-database-backup.timer if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then run_cmd systemctl restart otelcol-contrib.service fi @@ -851,8 +962,12 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then ensure_spacetime_owner_client_token if [[ -x "${CURRENT_LINK}/api-server" ]]; then run_cmd systemctl restart genarrative-api.service + run_cmd systemctl enable --now genarrative-external-generation-worker@1.service + run_cmd systemctl restart genarrative-external-generation-worker@1.service + run_cmd systemctl enable --now genarrative-external-generation-controller.service + run_cmd systemctl restart genarrative-external-generation-controller.service else - echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server 首次启动。后续 API deploy 会重启服务。" + echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server,跳过 api-server、外部生成 worker 和 controller 首次启动。后续 API deploy 会启用并启动默认 worker 与 controller。" fi fi diff --git a/scripts/ops/production-health-patrol.mjs b/scripts/ops/production-health-patrol.mjs index 219d8e29..01a1265c 100644 --- a/scripts/ops/production-health-patrol.mjs +++ b/scripts/ops/production-health-patrol.mjs @@ -20,9 +20,11 @@ const DEFAULT_PUBLIC_PATHS = [ const DEFAULT_SERVICES = [ 'genarrative-api.service', + 'genarrative-external-generation-controller.service', 'spacetimedb.service', 'nginx.service', ]; +const WORKER_SERVICE_PATTERN = 'genarrative-external-generation-worker@*.service'; function usage() { console.log(`Usage: @@ -216,6 +218,61 @@ async function checkService(serviceName, timeoutMs) { ); } +async function checkActiveWorkerInstances(config) { + const result = await runCommand( + 'systemctl', + [ + 'list-units', + WORKER_SERVICE_PATTERN, + '--type=service', + '--state=active', + '--no-legend', + '--plain', + '--no-pager', + ], + config.timeoutMs, + ); + if (result.code !== 0) { + return checkResult( + 'service:external-generation-workers', + 'CRITICAL', + '无法枚举外部生成 worker 实例', + { + command: result.command, + stderr: result.stderr.trim() || result.error, + }, + ); + } + + const services = result.stdout + .split('\n') + .map((line) => line.trim().split(/\s+/u)[0]) + .filter((service) => + /^genarrative-external-generation-worker@.+\.service$/u.test(service), + ); + + if (services.length === 0) { + return checkResult( + 'service:external-generation-workers', + 'CRITICAL', + '没有 active 的外部生成 worker 实例', + { + command: result.command, + }, + ); + } + + return checkResult( + 'service:external-generation-workers', + 'OK', + `${services.length} 个 worker active`, + { + command: result.command, + services, + }, + ); +} + function requestUrl(url, timeoutMs) { return new Promise((resolve) => { const startedAt = Date.now(); @@ -310,6 +367,10 @@ async function checkRecentJournal(config) { '-u', 'genarrative-api.service', '-u', + 'genarrative-external-generation-controller.service', + '-u', + WORKER_SERVICE_PATTERN, + '-u', 'spacetimedb.service', '-u', 'nginx.service', @@ -426,6 +487,7 @@ async function main() { for (const serviceName of DEFAULT_SERVICES) { checks.push(await checkService(serviceName, config.timeoutMs)); } + checks.push(await checkActiveWorkerInstances(config)); checks.push(await checkHttp('api:/healthz', joinUrl(config.apiBaseUrl, '/healthz'), config)); checks.push(await checkHttp('api:/readyz', joinUrl(config.apiBaseUrl, '/readyz'), config)); diff --git a/scripts/prepare-server-provision-tools.sh b/scripts/prepare-server-provision-tools.sh index edb44a12..78ef8bfb 100755 --- a/scripts/prepare-server-provision-tools.sh +++ b/scripts/prepare-server-provision-tools.sh @@ -9,7 +9,7 @@ OTELCOL_DOWNLOAD_ROOT="${OTELCOL_DOWNLOAD_ROOT:-https://github.com/open-telemetr OTELCOL_ARCHIVE_PATH="${OTELCOL_ARCHIVE_PATH:-}" OTELCOL_TARGET_BIN="${OTELCOL_TARGET_BIN:-/usr/local/bin/otelcol-contrib}" SPACETIME_INSTALLER_URL="${SPACETIME_INSTALLER_URL:-https://install.spacetimedb.com}" -SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.4.1}" +SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/download/v2.5.0}" SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" SPACETIME_ROOT="${SPACETIME_ROOT:-/stdb}" SPACETIME_EXPECTED_VERSION="${SPACETIME_EXPECTED_VERSION:-}" diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index f285faaf..a9d508fa 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" @@ -35,6 +51,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -64,6 +81,31 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.11.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -173,6 +215,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -197,6 +259,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + [[package]] name = "async-stream" version = "0.3.6" @@ -324,6 +392,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -377,6 +460,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "brotli" version = "3.5.0" @@ -409,6 +501,20 @@ name = "bytemuck" version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "byteorder-lite" @@ -432,6 +538,57 @@ dependencies = [ "serde_core", ] +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.11.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.11.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "castaway" version = "0.2.4" @@ -474,6 +631,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + [[package]] name = "chrono" version = "0.4.44" @@ -498,6 +664,43 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -540,12 +743,46 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -607,6 +844,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -665,9 +908,15 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "darling" version = "0.23.0" @@ -763,6 +1012,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -774,24 +1039,183 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecolor" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "eframe" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +dependencies = [ + "ahash", + "bitflags 2.11.1", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +dependencies = [ + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "emath" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +dependencies = [ + "bytemuck", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -839,6 +1263,30 @@ dependencies = [ "syn", ] +[[package]] +name = "epaint" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" + [[package]] name = "equivalent" version = "1.0.2" @@ -852,9 +1300,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "ethnum" version = "1.5.3" @@ -879,6 +1333,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -922,13 +1382,40 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -937,6 +1424,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1044,6 +1537,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1084,12 +1587,101 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.11.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + [[package]] name = "h2" version = "0.3.27" @@ -1109,6 +1701,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1121,7 +1725,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1131,6 +1735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "equivalent", + "foldhash 0.2.0", "rayon", "serde", "serde_core", @@ -1154,12 +1759,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hmac" version = "0.12.1" @@ -1342,7 +1959,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1499,6 +2116,7 @@ dependencies = [ "moxcms", "num-traits", "png", + "tiff", "zune-core", "zune-jpeg", ] @@ -1593,6 +2211,64 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -1639,6 +2315,12 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "langchainrust" version = "0.2.18" @@ -1690,6 +2372,34 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.8.1", +] + [[package]] name = "libwebp-sys" version = "0.9.6" @@ -1712,6 +2422,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1724,6 +2440,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1794,6 +2516,24 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -2074,6 +2814,31 @@ dependencies = [ "pxfm", ] +[[package]] +name = "naga" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap 2.14.0", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.18", + "unicode-ident", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2091,6 +2856,36 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2113,7 +2908,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2148,6 +2943,300 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -2164,7 +3253,7 @@ checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.11.1", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -2284,6 +3373,25 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2302,7 +3410,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -2397,6 +3505,12 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "platform-agent" version = "0.1.0" @@ -2543,12 +3657,41 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "pom" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postscript" version = "0.14.1" @@ -2589,6 +3732,15 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2598,6 +3750,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "prometheus" version = "0.14.0" @@ -2668,6 +3826,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2679,9 +3846,9 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2699,7 +3866,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", @@ -2718,7 +3885,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.59.0", ] @@ -2809,6 +3976,12 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" version = "1.12.0" @@ -2829,6 +4002,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2838,6 +4020,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -2887,6 +4078,12 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.11.27" @@ -2987,6 +4184,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3002,6 +4205,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3011,8 +4227,8 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", - "windows-sys 0.59.0", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -3071,6 +4287,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -3153,7 +4378,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -3292,6 +4517,13 @@ dependencies = [ "syn", ] +[[package]] +name = "server-manager-panel" +version = "0.1.0" +dependencies = [ + "eframe", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3384,6 +4616,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -3408,12 +4656,93 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.11.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.10" @@ -3502,9 +4831,9 @@ dependencies = [ [[package]] name = "spacetimedb" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "536e289684a624421eae421310d2f997a12f1be70e86b3692c87b837cbbb5a33" +checksum = "9fbe68c40e700df6586b1d6a94e52baaa9203d6425b50b0ac5870fe0f543d94d" dependencies = [ "anyhow", "bytemuck", @@ -3525,9 +4854,9 @@ dependencies = [ [[package]] name = "spacetimedb-bindings-macro" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53256d52b684b899b92b0fbd93f3a654458feb76290893ef13d57900fd38cfd5" +checksum = "3001a940fc424e322f2512ef9a81374ba5da8ea42735ccef7fcce480927bbff1" dependencies = [ "heck 0.4.1", "humantime", @@ -3539,18 +4868,18 @@ dependencies = [ [[package]] name = "spacetimedb-bindings-sys" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dba2d0109f7f2aa4cf6f349b8145268b214d0348b8e409005452d65b61139080" +checksum = "c418591c1da58ab6cfacdc57077996fe4a101b05fcd06889ab86d1cbc718216a" dependencies = [ "spacetimedb-primitives", ] [[package]] name = "spacetimedb-client-api-messages" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "014a905d52635c0dcb4fde3092fcc001e770d0d34c2e406b837d81196a630423" +checksum = "b3042c18f2b424fc7786b5bd5af59275b903c251ba41d48f11bef28c49f77f73" dependencies = [ "bytes", "bytestring", @@ -3570,9 +4899,9 @@ dependencies = [ [[package]] name = "spacetimedb-data-structures" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "823d3b3ecac3e8e948f254ee69e3fb848c8c0b4e6f92568bcfdeead1c98c4ff4" +checksum = "c6c1d60cf81d56be3801c0398b701051d9319f6c38e5ec0f9282b29a2c1b2dab" dependencies = [ "ahash", "crossbeam-queue", @@ -3585,9 +4914,9 @@ dependencies = [ [[package]] name = "spacetimedb-lib" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aecb06dc09f1e964a30f9de87404f21470a10f4f988ef668494b8ea53a4f920" +checksum = "523a8d4a746bb4403fe3e5241e3a72204fc1358e3b118b4f827de7673b6aabcb" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -3610,9 +4939,9 @@ dependencies = [ [[package]] name = "spacetimedb-memory-usage" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce5f8d17fe9432e0d6b6e04f46001ce0459ef70236e193bd5b17b3f71dd7731" +checksum = "cfa4e78b522fc9ee6e5dbd49c579d42584d6d7d6ce91d02c30471c085265f7df" dependencies = [ "decorum", "ethnum", @@ -3620,9 +4949,9 @@ dependencies = [ [[package]] name = "spacetimedb-metrics" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d178e28a736a326574c39753107b64a13cac7c04a57948f80caab8cadc1b7d8" +checksum = "226d91f133dcb792dd04ec3870828c4c1d7815a33646e8226894087f1680f8a9" dependencies = [ "arrayvec", "itertools", @@ -3632,9 +4961,9 @@ dependencies = [ [[package]] name = "spacetimedb-primitives" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2be40c852541973b8faf8c74957ade687579cfc5badd09b1060fb83e9a4fbec8" +checksum = "f625481d6715f9b0aba612599be6c4ab1028ab99425d23d75a268a49628a43f8" dependencies = [ "bitflags 2.11.1", "either", @@ -3646,18 +4975,18 @@ dependencies = [ [[package]] name = "spacetimedb-query-builder" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "857603c65a283e190b7e0a8bb62c8ff3fbd88cf97b0ae34454862e0caf2a30b7" +checksum = "0cf1d3fb9e170fbbfdf804414ffbb1aa4b4d913684cd68fd5b36ece561479b3c" dependencies = [ "spacetimedb-lib", ] [[package]] name = "spacetimedb-sats" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0290133e753457920bc975872edbb78559cf2003c78d3f2a8e3f2ecc288229d3" +checksum = "449ff63e22853eeaf903563f3cfaf8557ba0c84d0a62abcc388ac429670d321c" dependencies = [ "anyhow", "arrayvec", @@ -3688,9 +5017,9 @@ dependencies = [ [[package]] name = "spacetimedb-schema" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54cac9350fe39d35002089af31417d28c85d44eca66646a1515f99117db03e0" +checksum = "99e1d892e7d7fdaa297c565fba749f9a925fc102931187c98976c7c7ad97f80c" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -3719,9 +5048,9 @@ dependencies = [ [[package]] name = "spacetimedb-sdk" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82ac34a6f244a0b7114ae52c94db0b85ef3e4bddcfa54adcfc8198e0143b638a" +checksum = "115835ba9f558e43781aa6059247157f97f37d1968292bc866a03da906be4258" dependencies = [ "anymap3", "base64 0.21.7", @@ -3751,9 +5080,9 @@ dependencies = [ [[package]] name = "spacetimedb-sql-parser" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc9150d2dba445942d1c82b3d6e56f003ca024069eb43d1ccd06c1129ee1294" +checksum = "47a4ab48f838f62e93a593309963862494961339fd60b1555abe48792c6c50bb" dependencies = [ "derive_more", "spacetimedb-lib", @@ -3776,6 +5105,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -3854,7 +5189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3877,8 +5212,8 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", - "windows-sys 0.59.0", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -3943,6 +5278,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -4303,6 +5652,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tungstenite" version = "0.27.0" @@ -4340,6 +5695,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "type1-encoding-parser" version = "0.1.1" @@ -4382,6 +5746,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -4454,6 +5824,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4589,6 +5969,141 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.11.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -4609,6 +6124,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + [[package]] name = "webp" version = "0.3.1" @@ -4643,13 +6174,109 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +dependencies = [ + "arrayvec", + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "27.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.11.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap 2.14.0", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "27.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "27.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.18", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "27.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.18", + "web-sys", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4868,6 +6495,57 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.11.1", + "block2", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + [[package]] name = "winnow" version = "1.0.1" @@ -4987,6 +6665,69 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.11.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + [[package]] name = "yoke" version = "0.8.2" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index cdc461bd..2a6229c3 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -40,6 +40,7 @@ members = [ "crates/platform-wechat", "crates/platform-speech", "crates/platform-agent", + "crates/server-manager-panel", "crates/shared-contracts", "crates/shared-kernel", "crates/shared-logging", @@ -120,9 +121,9 @@ serde_urlencoded = "0.7" sha1 = "0.10" sha2 = "0.10" socket2 = "0.6" -spacetimedb = "=2.4.1" -spacetimedb-sdk = "=2.4.1" -spacetimedb-lib = { version = "=2.4.1", default-features = false } +spacetimedb = "=2.5.0" +spacetimedb-sdk = "=2.5.0" +spacetimedb-lib = { version = "=2.5.0", default-features = false } time = "0.3" tokio = "1" tokio-stream = "0.1" diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index dc38ad00..93df6459 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -56,7 +56,7 @@ shared-kernel = { workspace = true } shared-logging = { workspace = true } socket2 = { workspace = true } spacetime-client = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "signal", "process"] } tokio-stream = { workspace = true } futures-util = { workspace = true } time = { workspace = true, features = ["formatting"] } diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 633e0e00..d840369e 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -45,6 +45,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::assets::router(state.clone())) .merge(modules::editor_project::router(state.clone())) .merge(modules::platform::router(state.clone())) + .merge(modules::external_generation::router(state.clone())) .merge(modules::play_flow::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index c6430554..bc2dc80b 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -52,7 +52,7 @@ where match operation.await { Ok(value) => Ok(value), Err(error) => { - if points_consumed { + if points_consumed && should_refund_asset_operation_error(&error) { refund_asset_operation_points( state, owner_user_id, @@ -67,6 +67,20 @@ where } } +pub(crate) fn should_refund_asset_operation_error(error: &AppError) -> bool { + let message = error.body_text(); + // 中文注释:worker lease guard 拒绝表示当前进程已失去队列写权限; + // 这类 stale worker 失败不能补偿退款,否则可能冲掉后续合法 worker 的同一账本扣费。 + !(message.contains("external_generation_job") + && (message.contains("lease") + || message.contains("worker") + || message.contains("job_kind") + || message.contains("source_") + || message.contains("owner_user_id") + || message.contains("不存在") + || message.contains("不是 running 状态"))) +} + /// 资产操作统一预扣泥点;扣费流水 ID 由业务资源 ID 参与构造,保证重试幂等。 async fn consume_asset_operation_points( state: &AppState, @@ -249,4 +263,31 @@ mod tests { &SpacetimeClientError::Procedure("泥点余额不足".to_string()), )); } + + #[test] + fn asset_operation_billing_does_not_refund_stale_worker_lease_errors() { + let stale_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "external_generation_job lease 已过期", + })); + let completed_job_error = + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "external_generation_job 当前不是 running 状态", + })); + let missing_job_error = + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "external_generation_job 不存在", + })); + let ordinary_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "图片生成失败", + })); + + assert!(!should_refund_asset_operation_error(&stale_error)); + assert!(!should_refund_asset_operation_error(&completed_job_error)); + assert!(!should_refund_asset_operation_error(&missing_job_error)); + assert!(should_refund_asset_operation_error(&ordinary_error)); + } } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index b23bb782..b4af828a 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -22,6 +22,19 @@ pub struct AppConfig { pub bind_port: u16, pub listen_backlog: i32, pub worker_threads: Option, + pub process_role: ProcessRole, + pub external_generation_mode: ExternalGenerationMode, + pub external_generation_worker_id: String, + pub external_generation_worker_concurrency: usize, + pub external_generation_worker_poll_interval: Duration, + pub external_generation_worker_lease: Duration, + pub external_generation_controller_min_workers: usize, + pub external_generation_controller_max_workers: usize, + pub external_generation_controller_target_jobs_per_worker: usize, + pub external_generation_controller_poll_interval: Duration, + pub external_generation_controller_scale_down_idle_rounds: u32, + pub external_generation_controller_service_template: String, + pub external_generation_controller_dry_run: bool, pub max_concurrent_requests: Option, pub gallery_max_concurrent_requests: Option, pub detail_max_concurrent_requests: Option, @@ -171,6 +184,56 @@ pub struct AppConfig { pub slow_request_threshold_ms: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProcessRole { + Api, + ExternalGenerationWorker, + ExternalGenerationController, + All, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExternalGenerationMode { + Inline, + Queue, +} + +impl ExternalGenerationMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Inline => "inline", + Self::Queue => "queue", + } + } + + pub fn is_inline(self) -> bool { + matches!(self, Self::Inline) + } +} + +impl ProcessRole { + pub fn as_str(self) -> &'static str { + match self { + Self::Api => "api", + Self::ExternalGenerationWorker => "external-generation-worker", + Self::ExternalGenerationController => "external-generation-controller", + Self::All => "all", + } + } + + pub fn runs_http(self) -> bool { + matches!(self, Self::Api | Self::All) + } + + pub fn runs_external_generation_worker(self) -> bool { + matches!(self, Self::ExternalGenerationWorker | Self::All) + } + + pub fn runs_external_generation_controller(self) -> bool { + matches!(self, Self::ExternalGenerationController) + } +} + impl Default for AppConfig { fn default() -> Self { Self { @@ -178,6 +241,20 @@ impl Default for AppConfig { bind_port: 3000, listen_backlog: 1024, worker_threads: None, + process_role: ProcessRole::Api, + external_generation_mode: ExternalGenerationMode::Queue, + external_generation_worker_id: default_external_generation_worker_id(), + external_generation_worker_concurrency: 2, + external_generation_worker_poll_interval: Duration::from_millis(2_000), + external_generation_worker_lease: Duration::from_secs(3_600), + external_generation_controller_min_workers: 1, + external_generation_controller_max_workers: 8, + external_generation_controller_target_jobs_per_worker: 2, + external_generation_controller_poll_interval: Duration::from_millis(10_000), + external_generation_controller_scale_down_idle_rounds: 6, + external_generation_controller_service_template: + "genarrative-external-generation-worker@{}.service".to_string(), + external_generation_controller_dry_run: false, max_concurrent_requests: None, gallery_max_concurrent_requests: None, detail_max_concurrent_requests: None, @@ -374,6 +451,78 @@ impl AppConfig { if let Some(worker_threads) = read_first_usize_env(&["GENARRATIVE_API_WORKER_THREADS"]) { config.worker_threads = Some(worker_threads); } + if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) { + config.process_role = process_role; + } + if let Some(external_generation_mode) = + read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"]) + { + config.external_generation_mode = external_generation_mode; + } + if let Some(worker_id) = + read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"]) + { + config.external_generation_worker_id = worker_id; + } + if let Some(concurrency) = + read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY"]) + { + config.external_generation_worker_concurrency = concurrency.max(1); + } + if let Some(poll_interval_ms) = read_first_positive_u64_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS", + ]) { + config.external_generation_worker_poll_interval = + Duration::from_millis(poll_interval_ms); + } + if let Some(lease_seconds) = read_first_duration_seconds_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS", + ]) { + config.external_generation_worker_lease = Duration::from_secs(lease_seconds.max(1)); + } + if let Some(min_workers) = + read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MIN_WORKERS"]) + { + config.external_generation_controller_min_workers = min_workers; + } + if let Some(max_workers) = + read_first_usize_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_MAX_WORKERS"]) + { + config.external_generation_controller_max_workers = max_workers; + } + if config.external_generation_controller_max_workers + < config.external_generation_controller_min_workers + { + config.external_generation_controller_max_workers = + config.external_generation_controller_min_workers; + } + if let Some(target_jobs_per_worker) = read_first_usize_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_TARGET_JOBS_PER_WORKER", + ]) { + config.external_generation_controller_target_jobs_per_worker = + target_jobs_per_worker.max(1); + } + if let Some(poll_interval_ms) = read_first_positive_u64_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_POLL_INTERVAL_MS", + ]) { + config.external_generation_controller_poll_interval = + Duration::from_millis(poll_interval_ms); + } + if let Some(idle_rounds) = read_first_u32_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SCALE_DOWN_IDLE_ROUNDS", + ]) { + config.external_generation_controller_scale_down_idle_rounds = idle_rounds; + } + if let Some(service_template) = read_first_non_empty_env(&[ + "GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_SERVICE_TEMPLATE", + ]) { + config.external_generation_controller_service_template = service_template; + } + if let Some(dry_run) = + read_first_bool_env(&["GENARRATIVE_EXTERNAL_GENERATION_CONTROLLER_DRY_RUN"]) + { + config.external_generation_controller_dry_run = dry_run; + } if let Some(max_concurrent_requests) = read_first_usize_env(&["GENARRATIVE_API_MAX_CONCURRENT_REQUESTS"]) { @@ -1053,6 +1202,22 @@ fn read_first_llm_provider_env(keys: &[&str]) -> Option { }) } +fn read_first_process_role_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_process_role(&value)) + }) +} + +fn read_first_external_generation_mode_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_external_generation_mode(&value)) + }) +} + fn read_first_positive_u32_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -1100,6 +1265,49 @@ fn read_first_u8_env(keys: &[&str]) -> Option { .find_map(|key| env::var(key).ok().and_then(|value| parse_u8(&value))) } +fn default_external_generation_worker_id() -> String { + let host = env::var("HOSTNAME") + .or_else(|_| env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "local".to_string()); + format!("{}-{}", host.trim(), std::process::id()) +} + +fn parse_process_role(value: &str) -> Option { + match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { + "api" => Some(ProcessRole::Api), + "external-generation-worker" | "external_generation_worker" | "worker" => { + Some(ProcessRole::ExternalGenerationWorker) + } + "external-generation-controller" | "external_generation_controller" | "controller" => { + Some(ProcessRole::ExternalGenerationController) + } + "all" => Some(ProcessRole::All), + _ => None, + } +} + +fn parse_external_generation_mode(value: &str) -> Option { + match trim_quoted_env_value(value).to_ascii_lowercase().as_str() { + "inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline), + "queue" | "queued" | "worker" | "async" | "asynchronous" => { + Some(ExternalGenerationMode::Queue) + } + _ => None, + } +} + +fn trim_quoted_env_value(raw: &str) -> &str { + let raw = raw.trim(); + raw.strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + raw.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(raw) + .trim() +} + fn read_first_positive_u16_env(keys: &[&str]) -> Option { keys.iter().find_map(|key| { env::var(key) @@ -1220,7 +1428,8 @@ fn parse_positive_u16(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, parse_bool, + AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode, + LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role, }; use std::sync::{Mutex, OnceLock}; @@ -1262,6 +1471,91 @@ mod tests { assert_eq!(parse_bool("'off'"), Some(false)); } + #[test] + fn process_role_controls_http_and_external_generation_worker_roles() { + assert_eq!(parse_process_role("api"), Some(ProcessRole::Api)); + assert_eq!( + parse_process_role("\"external-generation-worker\""), + Some(ProcessRole::ExternalGenerationWorker) + ); + assert_eq!( + parse_process_role("'external_generation_worker'"), + Some(ProcessRole::ExternalGenerationWorker) + ); + assert_eq!( + parse_process_role("worker"), + Some(ProcessRole::ExternalGenerationWorker) + ); + assert_eq!( + parse_process_role("controller"), + Some(ProcessRole::ExternalGenerationController) + ); + assert_eq!( + parse_process_role("'external_generation_controller'"), + Some(ProcessRole::ExternalGenerationController) + ); + assert_eq!(parse_process_role("all"), Some(ProcessRole::All)); + assert_eq!(parse_process_role("unknown"), None); + + assert!(ProcessRole::Api.runs_http()); + assert!(!ProcessRole::Api.runs_external_generation_worker()); + assert!(!ProcessRole::Api.runs_external_generation_controller()); + assert!(!ProcessRole::ExternalGenerationWorker.runs_http()); + assert!(ProcessRole::ExternalGenerationWorker.runs_external_generation_worker()); + assert!(!ProcessRole::ExternalGenerationWorker.runs_external_generation_controller()); + assert!(!ProcessRole::ExternalGenerationController.runs_http()); + assert!(!ProcessRole::ExternalGenerationController.runs_external_generation_worker()); + assert!(ProcessRole::ExternalGenerationController.runs_external_generation_controller()); + assert!(ProcessRole::All.runs_http()); + assert!(ProcessRole::All.runs_external_generation_worker()); + assert!(!ProcessRole::All.runs_external_generation_controller()); + } + + #[test] + fn external_generation_mode_parses_inline_and_queue_aliases() { + assert_eq!( + parse_external_generation_mode("inline"), + Some(ExternalGenerationMode::Inline) + ); + assert_eq!( + parse_external_generation_mode("'sync'"), + Some(ExternalGenerationMode::Inline) + ); + assert_eq!( + parse_external_generation_mode("\"queue\""), + Some(ExternalGenerationMode::Queue) + ); + assert_eq!( + parse_external_generation_mode("worker"), + Some(ExternalGenerationMode::Queue) + ); + assert_eq!(parse_external_generation_mode("unknown"), None); + + assert!(ExternalGenerationMode::Inline.is_inline()); + assert!(!ExternalGenerationMode::Queue.is_inline()); + } + + #[test] + fn from_env_reads_external_generation_mode() { + let _guard = ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .expect("env lock"); + unsafe { + std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline"); + } + + let config = AppConfig::from_env(); + + assert_eq!( + config.external_generation_mode, + ExternalGenerationMode::Inline + ); + unsafe { + std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE"); + } + } + #[test] fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() { let _guard = ENV_LOCK diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs new file mode 100644 index 00000000..345dbe49 --- /dev/null +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -0,0 +1,108 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::Response, +}; +use serde_json::json; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, + ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview, + ExternalGenerationQueueOverviewResponse, +}; +use spacetime_client::{ + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, + ExternalGenerationQueueStatsRecord, SpacetimeClientError, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation"; + +pub async fn get_external_generation_queue_overview( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let stats = state + .spacetime_client() + .get_external_generation_queue_stats() + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationQueueOverviewResponse { + overview: map_external_generation_queue_overview(stats), + }, + )) +} + +pub async fn get_external_generation_job_status( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(job_id): Path, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let job = state + .spacetime_client() + .get_external_generation_job(ExternalGenerationJobGetRecordInput { + job_id, + owner_user_id, + }) + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationJobStatusResponse { + job: map_external_generation_job_status(job), + }, + )) +} + +fn map_external_generation_queue_overview( + stats: ExternalGenerationQueueStatsRecord, +) -> ExternalGenerationQueueOverview { + ExternalGenerationQueueOverview { + pending_count: stats.pending_count, + running_count: stats.running_active_count, + updated_at_micros: stats.now_micros, + } +} + +fn map_external_generation_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + let (status, phase_detail, progress) = match job.status.as_str() { + "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), + "failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), + _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), + }; + + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status, + phase_label: job.request_label, + phase_detail: phase_detail.to_string(), + progress, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +fn external_generation_error_response( + request_context: &RequestContext, + error: SpacetimeClientError, +) -> Response { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": EXTERNAL_GENERATION_PROVIDER, + "message": error.to_string(), + })) + .into_response_with_context(Some(request_context)) +} diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs new file mode 100644 index 00000000..ec90016a --- /dev/null +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -0,0 +1,750 @@ +use std::{future::Future, io, pin::Pin, time::Duration}; + +use axum::extract::FromRef; +use serde_json::json; +use shared_kernel::offset_datetime_to_unix_micros; +use spacetime_client::{ + ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, +}; +use tokio::{ + task::JoinSet, + time::{Instant, sleep}, +}; +use tracing::{error, info, warn}; + +use crate::{ + jump_hop::{ + JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload, + execute_jump_hop_compile_draft_worker_job, + }, + puzzle::{ + ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload, + PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload, + execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job, + execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim, + }, + puzzle_clear::{ + PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload, + execute_puzzle_clear_compile_draft_worker_job, + }, + request_context::RequestContext, + state::{AppState, PuzzleApiState}, + wooden_fish::{ + WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload, + execute_wooden_fish_generate_image_assets_worker_job, + }, +}; + +pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft"; +pub(crate) const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images"; +pub(crate) const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background"; + +pub(crate) async fn run_external_generation_worker(state: AppState) -> Result<(), io::Error> { + let worker_id = state.config.external_generation_worker_id.clone(); + let concurrency = state.config.external_generation_worker_concurrency.max(1); + let poll_interval = state.config.external_generation_worker_poll_interval; + let lease = state.config.external_generation_worker_lease; + let mut tasks = JoinSet::new(); + let mut shutdown = external_generation_worker_shutdown_signal(); + + info!( + worker_id, + concurrency, + poll_interval_ms = poll_interval.as_millis(), + lease_seconds = lease.as_secs(), + "external generation worker 已启动" + ); + + loop { + while tasks.len() >= concurrency { + if await_worker_task_or_shutdown(&mut tasks, &mut shutdown).await { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + } + + let available = concurrency.saturating_sub(tasks.len()).max(1); + let now_micros = current_utc_micros(); + let lease_expires_at_micros = now_micros.saturating_add(duration_micros_i64(lease)); + + let claim_jobs = state.spacetime_client().claim_external_generation_jobs( + ExternalGenerationJobClaimRecordInput { + worker_id: worker_id.clone(), + limit: available.min(u32::MAX as usize) as u32, + lease_expires_at_micros, + claimed_at_micros: now_micros, + }, + ); + tokio::pin!(claim_jobs); + let jobs = match tokio::select! { + _ = shutdown.as_mut() => { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + result = &mut claim_jobs => result + } { + Ok(jobs) => jobs, + Err(error) => { + error!(error = %error, "领取外部生成任务失败,等待下一轮重试"); + if await_one_task_or_sleep_or_shutdown( + &mut tasks, + sleep(poll_interval), + &mut shutdown, + ) + .await + { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + continue; + } + }; + + if jobs.is_empty() { + if await_one_task_or_sleep_or_shutdown(&mut tasks, sleep(poll_interval), &mut shutdown) + .await + { + drain_external_generation_worker_tasks(&mut tasks).await; + return Ok(()); + } + continue; + } + + for job in jobs { + let state = state.clone(); + let worker_id = worker_id.clone(); + tasks.spawn(async move { + if let Err(error) = + process_external_generation_job(state, worker_id, lease, job).await + { + error!(error = %error, "external generation worker 执行任务失败"); + } + }); + } + } +} + +type ExternalGenerationShutdownSignal = Pin + Send>>; + +fn external_generation_worker_shutdown_signal() -> ExternalGenerationShutdownSignal { + Box::pin(async { + wait_for_external_generation_worker_shutdown_signal().await; + }) +} + +#[cfg(unix)] +async fn wait_for_external_generation_worker_shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = signal(SignalKind::terminate()).ok(); + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + warn!(error = %error, "external generation worker 监听 SIGINT 失败"); + } + } + _ = async { + if let Some(sigterm) = sigterm.as_mut() { + sigterm.recv().await; + } else { + std::future::pending::<()>().await; + } + } => {} + } +} + +#[cfg(not(unix))] +async fn wait_for_external_generation_worker_shutdown_signal() { + if let Err(error) = tokio::signal::ctrl_c().await { + warn!(error = %error, "external generation worker 监听 Ctrl-C 失败"); + } +} + +async fn await_worker_task(tasks: &mut JoinSet<()>) { + if let Some(result) = tasks.join_next().await + && let Err(error) = result + { + error!(error = %error, "external generation worker 子任务 panic"); + } +} + +async fn await_worker_task_or_shutdown( + tasks: &mut JoinSet<()>, + shutdown: &mut ExternalGenerationShutdownSignal, +) -> bool { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = await_worker_task(tasks) => false, + } +} + +async fn await_one_task_or_sleep_or_shutdown( + tasks: &mut JoinSet<()>, + sleeper: impl Future, + shutdown: &mut ExternalGenerationShutdownSignal, +) -> bool { + tokio::pin!(sleeper); + if tasks.is_empty() { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = &mut sleeper => false, + } + } else { + tokio::select! { + _ = shutdown.as_mut() => true, + _ = &mut sleeper => false, + result = tasks.join_next() => { + if let Some(Err(error)) = result { + error!(error = %error, "external generation worker 子任务 panic"); + } + false + } + } + } +} + +async fn drain_external_generation_worker_tasks(tasks: &mut JoinSet<()>) { + info!( + in_flight_jobs = tasks.len(), + "external generation worker 收到停机信号,停止领取新任务并等待当前任务完成" + ); + while !tasks.is_empty() { + await_worker_task(tasks).await; + } + info!("external generation worker 已完成优雅停机"); +} + +async fn process_external_generation_job( + state: AppState, + worker_id: String, + lease: Duration, + job: ExternalGenerationJobRecord, +) -> Result<(), String> { + let heartbeat_interval = external_generation_worker_heartbeat_interval(lease); + let work = process_external_generation_job_once(state.clone(), worker_id.clone(), job.clone()); + tokio::pin!(work); + let heartbeat = sleep(heartbeat_interval); + tokio::pin!(heartbeat); + + loop { + tokio::select! { + biased; + result = &mut work => return result, + _ = &mut heartbeat => { + renew_job_lease(&state, &worker_id, &job, lease).await?; + heartbeat.as_mut().reset(Instant::now() + heartbeat_interval); + } + } + } +} + +async fn process_external_generation_job_once( + state: AppState, + worker_id: String, + job: ExternalGenerationJobRecord, +) -> Result<(), String> { + match job.job_kind.as_str() { + PUZZLE_COMPILE_DRAFT_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("拼图生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + let puzzle_state = PuzzleApiState::from_ref(&state); + let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?; + match execute_puzzle_compile_draft_worker_job( + &puzzle_state, + &request_context, + payload.clone(), + write_guard, + ) + .await + { + Ok(session) => { + let result = complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "progressPercent": session.progress_percent, + }) + .to_string(), + ), + ) + .await; + if result.is_ok() { + release_puzzle_compile_background_claim(&puzzle_state, &payload); + } + result + } + Err(error) => { + let message = error.body_text(); + let should_release_claim = error.should_fail_queue_job(); + let result = fail_queue_job_after_worker_error( + &state, &worker_id, &job, &error, &message, + ) + .await; + if result.is_ok() && should_release_claim { + release_puzzle_compile_background_claim(&puzzle_state, &payload); + } + result?; + Err(message) + } + } + } + PUZZLE_GENERATE_IMAGES_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("拼图关卡图片生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + let puzzle_state = PuzzleApiState::from_ref(&state); + let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?; + match execute_puzzle_generate_images_worker_job( + &puzzle_state, + &request_context, + payload, + write_guard, + ) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "progressPercent": session.progress_percent, + }) + .to_string(), + ), + ) + .await + } + Err(error) => { + let message = error.body_text(); + fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message) + .await?; + Err(message) + } + } + } + PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("拼图 UI 背景图生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + let puzzle_state = PuzzleApiState::from_ref(&state); + let write_guard = build_external_generation_write_lease_guard(&worker_id, &job)?; + match execute_puzzle_generate_ui_background_worker_job( + &puzzle_state, + &request_context, + payload, + write_guard, + ) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "progressPercent": session.progress_percent, + }) + .to_string(), + ), + ) + .await + } + Err(error) => { + let message = error.body_text(); + fail_queue_job_after_worker_error(&state, &worker_id, &job, &error, &message) + .await?; + Err(message) + } + } + } + JUMP_HOP_COMPILE_DRAFT_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("跳一跳生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_jump_hop_compile_draft_worker_job(&state, &request_context, payload).await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } + PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("拼消消生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } + WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("敲木鱼图片生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_wooden_fish_generate_image_assets_worker_job( + &state, + &request_context, + payload, + ) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } + unknown => { + warn!( + job_id = job.job_id, + job_kind = unknown, + "external generation worker 收到暂不支持的任务类型" + ); + fail_job( + &state, + &worker_id, + &job, + format!("暂不支持的外部生成任务类型:{unknown}"), + ) + .await + } + } +} + +async fn response_error_message(response: axum::response::Response) -> String { + use axum::body::to_bytes; + let status = response.status(); + let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await { + Ok(bytes) => bytes, + Err(error) => { + return format!("外部生成任务失败:{status},响应读取失败:{error}"); + } + }; + let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string(); + if body_text.is_empty() { + return format!("外部生成任务失败:{status}"); + } + if let Ok(body_json) = serde_json::from_str::(&body_text) + && let Some(message) = body_json + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + { + return message.to_string(); + } + body_text +} + +async fn fail_queue_job_after_worker_error( + state: &AppState, + worker_id: &str, + job: &ExternalGenerationJobRecord, + error: &crate::puzzle::PuzzleExternalGenerationWorkerError, + message: &str, +) -> Result<(), String> { + if error.should_fail_queue_job() { + return fail_job(state, worker_id, job, message.to_string()).await; + } + + warn!( + job_id = job.job_id, + job_kind = job.job_kind, + "external generation worker 业务失败态尚未写回,保留任务租约等待后续重试" + ); + Ok(()) +} + +async fn complete_job( + state: &AppState, + worker_id: &str, + job: &ExternalGenerationJobRecord, + result_payload_json: Option, +) -> Result<(), String> { + state + .spacetime_client() + .complete_external_generation_job(ExternalGenerationJobCompleteRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + result_payload_json, + completed_at_micros: current_utc_micros(), + }) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +async fn fail_job( + state: &AppState, + worker_id: &str, + job: &ExternalGenerationJobRecord, + error_message: String, +) -> Result<(), String> { + let now_micros = current_utc_micros(); + state + .spacetime_client() + .fail_external_generation_job(ExternalGenerationJobFailRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + error_message, + retry_after_micros: now_micros.saturating_add(60_000_000), + failed_at_micros: now_micros, + }) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +async fn renew_job_lease( + state: &AppState, + worker_id: &str, + job: &ExternalGenerationJobRecord, + lease: Duration, +) -> Result<(), String> { + let now_micros = current_utc_micros(); + state + .spacetime_client() + .renew_external_generation_job_lease(ExternalGenerationJobRenewLeaseRecordInput { + job_id: job.job_id.clone(), + worker_id: worker_id.to_string(), + lease_token: require_job_lease_token(job)?, + lease_expires_at_micros: now_micros.saturating_add(duration_micros_i64(lease)), + renewed_at_micros: now_micros, + }) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn require_job_lease_token(job: &ExternalGenerationJobRecord) -> Result { + job.lease_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| format!("external_generation_job {} 缺少 lease token", job.job_id)) +} + +fn build_external_generation_write_lease_guard( + worker_id: &str, + job: &ExternalGenerationJobRecord, +) -> Result { + Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job( + job.job_id.clone(), + worker_id.to_string(), + require_job_lease_token(job)?, + )) +} + +fn duration_micros_i64(duration: Duration) -> i64 { + duration.as_micros().min(i64::MAX as u128) as i64 +} + +fn external_generation_worker_heartbeat_interval(lease: Duration) -> Duration { + let heartbeat_millis = (lease.as_millis() / 3).clamp(250, 30_000) as u64; + Duration::from_millis(heartbeat_millis) +} + +fn current_utc_micros() -> i64 { + offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worker_write_guard_uses_claimed_job_lease_token() { + let job = external_generation_job_record_fixture(Some("lease-1")); + + let guard = build_external_generation_write_lease_guard("worker-a", &job) + .expect("guard should build"); + + assert_eq!(guard.job_id.as_deref(), Some("extgen-1")); + assert_eq!(guard.worker_id.as_deref(), Some("worker-a")); + assert_eq!(guard.lease_token.as_deref(), Some("lease-1")); + } + + #[test] + fn worker_write_guard_requires_claimed_job_lease_token() { + let job = external_generation_job_record_fixture(None); + + let error = build_external_generation_write_lease_guard("worker-a", &job) + .expect_err("missing token should fail"); + + assert!(error.contains("缺少 lease token")); + } + + fn external_generation_job_record_fixture( + lease_token: Option<&str>, + ) -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: "extgen-1".to_string(), + dedupe_key: "puzzle:generate_puzzle_images:session-1:extgen-1".to_string(), + job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(), + owner_user_id: "user-1".to_string(), + source_module: "puzzle".to_string(), + source_entity_id: "session-1:puzzle-level-1".to_string(), + request_label: "拼图关卡图片生成".to_string(), + request_payload_json: "{}".to_string(), + status: "running".to_string(), + attempt: 1, + max_attempts: 1, + last_error_message: None, + worker_id: Some("worker-a".to_string()), + lease_expires_at: Some("2026-06-03T00:00:00Z".to_string()), + available_at: "2026-06-03T00:00:00Z".to_string(), + result_payload_json: None, + created_at: "2026-06-03T00:00:00Z".to_string(), + started_at: Some("2026-06-03T00:00:00Z".to_string()), + completed_at: None, + updated_at: "2026-06-03T00:00:00Z".to_string(), + lease_token: lease_token.map(ToOwned::to_owned), + } + } +} diff --git a/server-rs/crates/api-server/src/external_generation_worker_controller.rs b/server-rs/crates/api-server/src/external_generation_worker_controller.rs new file mode 100644 index 00000000..3c4e588c --- /dev/null +++ b/server-rs/crates/api-server/src/external_generation_worker_controller.rs @@ -0,0 +1,465 @@ +use std::{collections::BTreeSet, future::Future, io, pin::Pin, process::Stdio, time::Duration}; + +use spacetime_client::ExternalGenerationQueueStatsRecord; +use tokio::{ + process::Command, + time::{Instant, sleep}, +}; +use tracing::{error, info, warn}; + +use crate::state::AppState; + +#[derive(Clone, Debug)] +struct ExternalGenerationWorkerControllerConfig { + min_workers: usize, + max_workers: usize, + target_jobs_per_worker: usize, + poll_interval: Duration, + scale_down_idle_rounds: u32, + service_template: String, + dry_run: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ExternalGenerationWorkerControllerDecision { + desired_workers: usize, + should_scale_down: bool, + idle_rounds: u32, +} + +#[derive(Debug, Default)] +struct ExternalGenerationWorkerControllerState { + idle_rounds: u32, +} + +pub(crate) async fn run_external_generation_worker_controller( + state: AppState, +) -> Result<(), io::Error> { + let config = ExternalGenerationWorkerControllerConfig::from_state(&state); + let mut controller_state = ExternalGenerationWorkerControllerState::default(); + let mut shutdown = external_generation_controller_shutdown_signal(); + + info!( + min_workers = config.min_workers, + max_workers = config.max_workers, + target_jobs_per_worker = config.target_jobs_per_worker, + poll_interval_ms = config.poll_interval.as_millis(), + scale_down_idle_rounds = config.scale_down_idle_rounds, + service_template = config.service_template, + dry_run = config.dry_run, + "external generation worker controller 已启动" + ); + + loop { + let tick = run_external_generation_controller_tick(&state, &config, &mut controller_state); + tokio::select! { + _ = shutdown.as_mut() => { + info!("external generation worker controller 收到停机信号"); + return Ok(()); + } + result = tick => { + if let Err(error) = result { + error!(error = %error, "external generation worker controller 本轮扩缩容失败"); + } + } + } + + let next_tick = sleep(config.poll_interval); + tokio::pin!(next_tick); + tokio::select! { + _ = shutdown.as_mut() => { + info!("external generation worker controller 收到停机信号"); + return Ok(()); + } + _ = &mut next_tick => {} + } + } +} + +async fn run_external_generation_controller_tick( + state: &AppState, + config: &ExternalGenerationWorkerControllerConfig, + controller_state: &mut ExternalGenerationWorkerControllerState, +) -> Result<(), String> { + let stats = state + .spacetime_client() + .get_external_generation_queue_stats() + .await + .map_err(|error| format!("读取 external_generation_job 队列统计失败:{error}"))?; + let active_instances = list_active_external_generation_worker_instances(config).await?; + let current_workers = active_instances.len(); + let decision = decide_external_generation_worker_target( + &stats, + current_workers, + controller_state.idle_rounds, + config, + ); + controller_state.idle_rounds = decision.idle_rounds; + + info!( + pending = stats.pending_count, + delayed_pending = stats.delayed_pending_count, + claimable = stats.claimable_count, + running_active = stats.running_active_count, + expired_running = stats.expired_running_count, + oldest_claimable_age_ms = stats.oldest_claimable_age_micros.unwrap_or(0) / 1_000, + current_workers, + desired_workers = decision.desired_workers, + idle_rounds = decision.idle_rounds, + "external generation worker controller 完成队列评估" + ); + + reconcile_external_generation_worker_instances(config, &active_instances, &decision).await +} + +fn decide_external_generation_worker_target( + stats: &ExternalGenerationQueueStatsRecord, + current_workers: usize, + previous_idle_rounds: u32, + config: &ExternalGenerationWorkerControllerConfig, +) -> ExternalGenerationWorkerControllerDecision { + let pressure = stats + .claimable_pending_count + .saturating_add(stats.running_active_count) + .saturating_add(stats.expired_running_count); + let desired_from_pressure = + ceil_div_usize(pressure as usize, config.target_jobs_per_worker.max(1)); + let desired_workers = desired_from_pressure.clamp(config.min_workers, config.max_workers); + let is_idle = stats.claimable_count == 0 + && stats.expired_running_count == 0 + && stats.running_active_count == 0 + && desired_workers <= config.min_workers; + let idle_rounds = if is_idle { + previous_idle_rounds.saturating_add(1) + } else { + 0 + }; + let should_scale_down = current_workers > desired_workers + && idle_rounds >= config.scale_down_idle_rounds + && config.scale_down_idle_rounds > 0; + + ExternalGenerationWorkerControllerDecision { + desired_workers, + should_scale_down, + idle_rounds, + } +} + +async fn reconcile_external_generation_worker_instances( + config: &ExternalGenerationWorkerControllerConfig, + active_instances: &BTreeSet, + decision: &ExternalGenerationWorkerControllerDecision, +) -> Result<(), String> { + let current_workers = active_instances.len(); + let mut started = 0usize; + for instance in 1..=config.max_workers { + if current_workers.saturating_add(started) >= decision.desired_workers { + break; + } + if !active_instances.contains(&instance) { + systemctl_worker_instance(config, "start", instance).await?; + started = started.saturating_add(1); + } + } + + if decision.desired_workers > current_workers && started == 0 { + warn!( + current_workers, + desired_workers = decision.desired_workers, + "external generation worker controller 未找到可启动的缺口实例" + ); + } + if started > 0 { + return Ok(()); + } + + if decision.should_scale_down && decision.desired_workers < current_workers { + if let Some(instance) = active_instances + .iter() + .rev() + .copied() + .find(|instance| *instance > config.min_workers.max(1)) + { + systemctl_worker_instance(config, "stop", instance).await?; + } + } + + Ok(()) +} + +async fn list_active_external_generation_worker_instances( + config: &ExternalGenerationWorkerControllerConfig, +) -> Result, String> { + let mut active_instances = BTreeSet::new(); + for instance in 1..=config.max_workers { + if is_external_generation_worker_instance_active(config, instance).await? { + active_instances.insert(instance); + } + } + Ok(active_instances) +} + +async fn is_external_generation_worker_instance_active( + config: &ExternalGenerationWorkerControllerConfig, + instance: usize, +) -> Result { + let service = format_worker_service_name(&config.service_template, instance)?; + if config.dry_run { + return Ok(instance <= config.min_workers); + } + + let output = Command::new("systemctl") + .arg("is-active") + .arg("--quiet") + .arg(&service) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .output() + .await + .map_err(|error| format!("执行 systemctl is-active {service} 失败:{error}"))?; + Ok(output.status.success()) +} + +async fn systemctl_worker_instance( + config: &ExternalGenerationWorkerControllerConfig, + action: &str, + instance: usize, +) -> Result<(), String> { + let service = format_worker_service_name(&config.service_template, instance)?; + if config.dry_run { + info!( + action, + service, "external generation worker controller dry-run 跳过 systemctl" + ); + return Ok(()); + } + + let started_at = Instant::now(); + let output = Command::new("systemctl") + .arg(action) + .arg(&service) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|error| format!("执行 systemctl {action} {service} 失败:{error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "systemctl {action} {service} 返回失败 status={} stderr={}", + output.status, stderr + )); + } + + info!( + action, + service, + elapsed_ms = started_at.elapsed().as_millis(), + "external generation worker controller 已执行 systemctl" + ); + Ok(()) +} + +fn format_worker_service_name(template: &str, instance: usize) -> Result { + let instance = instance.to_string(); + if template.contains("{}") { + return Ok(template.replacen("{}", &instance, 1)); + } + if template.contains("%i") { + return Ok(template.replacen("%i", &instance, 1)); + } + Err("external generation controller service template 必须包含 {} 或 %i".to_string()) +} + +fn ceil_div_usize(value: usize, divisor: usize) -> usize { + if value == 0 { + 0 + } else { + value.saturating_add(divisor.saturating_sub(1)) / divisor.max(1) + } +} + +impl ExternalGenerationWorkerControllerConfig { + fn from_state(state: &AppState) -> Self { + let min_workers = state.config.external_generation_controller_min_workers; + let max_workers = state + .config + .external_generation_controller_max_workers + .max(min_workers); + Self { + min_workers, + max_workers, + target_jobs_per_worker: state + .config + .external_generation_controller_target_jobs_per_worker + .max(1), + poll_interval: state.config.external_generation_controller_poll_interval, + scale_down_idle_rounds: state + .config + .external_generation_controller_scale_down_idle_rounds, + service_template: state + .config + .external_generation_controller_service_template + .clone(), + dry_run: state.config.external_generation_controller_dry_run, + } + } +} + +type ExternalGenerationControllerShutdownSignal = Pin + Send>>; + +fn external_generation_controller_shutdown_signal() -> ExternalGenerationControllerShutdownSignal { + Box::pin(async { + wait_for_external_generation_controller_shutdown_signal().await; + }) +} + +#[cfg(unix)] +async fn wait_for_external_generation_controller_shutdown_signal() { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = signal(SignalKind::terminate()).ok(); + tokio::select! { + result = tokio::signal::ctrl_c() => { + if let Err(error) = result { + warn!(error = %error, "external generation worker controller 监听 SIGINT 失败"); + } + } + _ = async { + if let Some(sigterm) = sigterm.as_mut() { + sigterm.recv().await; + } else { + std::future::pending::<()>().await; + } + } => {} + } +} + +#[cfg(not(unix))] +async fn wait_for_external_generation_controller_shutdown_signal() { + if let Err(error) = tokio::signal::ctrl_c().await { + warn!(error = %error, "external generation worker controller 监听 Ctrl-C 失败"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scales_up_to_max_when_queue_pressure_is_high() { + let config = controller_config_fixture(); + let stats = stats_fixture(120, 0, 8); + + let decision = decide_external_generation_worker_target(&stats, 1, 0, &config); + + assert_eq!(decision.desired_workers, 8); + assert!(!decision.should_scale_down); + assert_eq!(decision.idle_rounds, 0); + } + + #[test] + fn scale_down_requires_consecutive_idle_rounds() { + let config = controller_config_fixture(); + let stats = stats_fixture(0, 0, 0); + + let first = decide_external_generation_worker_target(&stats, 5, 0, &config); + let ready = decide_external_generation_worker_target( + &stats, + 5, + config.scale_down_idle_rounds.saturating_sub(1), + &config, + ); + + assert_eq!(first.desired_workers, config.min_workers); + assert!(!first.should_scale_down); + assert!(ready.should_scale_down); + } + + #[test] + fn running_jobs_hold_capacity_before_scale_down() { + let config = controller_config_fixture(); + let stats = stats_fixture(0, 6, 0); + + let decision = decide_external_generation_worker_target(&stats, 5, 5, &config); + + assert_eq!(decision.desired_workers, 3); + assert!(!decision.should_scale_down); + assert_eq!(decision.idle_rounds, 0); + } + + #[test] + fn expired_running_jobs_are_not_counted_twice_as_claimable_pressure() { + let config = controller_config_fixture(); + let stats = stats_fixture(0, 0, 3); + + let decision = decide_external_generation_worker_target(&stats, 1, 0, &config); + + assert_eq!(decision.desired_workers, 2); + assert!(!decision.should_scale_down); + } + + #[test] + fn formats_worker_service_name_with_supported_templates() { + assert_eq!( + format_worker_service_name("genarrative-external-generation-worker@{}.service", 3) + .expect("format"), + "genarrative-external-generation-worker@3.service" + ); + assert_eq!( + format_worker_service_name("worker@%i.service", 7).expect("format"), + "worker@7.service" + ); + assert!(format_worker_service_name("worker.service", 1).is_err()); + } + + #[tokio::test] + async fn dry_run_reconcile_does_not_start_low_number_gaps_when_capacity_is_enough() { + let config = controller_config_fixture(); + let active_instances = BTreeSet::from([3usize, 4usize]); + let decision = ExternalGenerationWorkerControllerDecision { + desired_workers: 2, + should_scale_down: false, + idle_rounds: 0, + }; + + let result = + reconcile_external_generation_worker_instances(&config, &active_instances, &decision) + .await; + + assert!(result.is_ok()); + } + + fn controller_config_fixture() -> ExternalGenerationWorkerControllerConfig { + ExternalGenerationWorkerControllerConfig { + min_workers: 1, + max_workers: 8, + target_jobs_per_worker: 2, + poll_interval: Duration::from_secs(10), + scale_down_idle_rounds: 3, + service_template: "genarrative-external-generation-worker@{}.service".to_string(), + dry_run: true, + } + } + + fn stats_fixture( + claimable_pending_count: u32, + running_active_count: u32, + expired_running_count: u32, + ) -> ExternalGenerationQueueStatsRecord { + let claimable_count = claimable_pending_count.saturating_add(expired_running_count); + ExternalGenerationQueueStatsRecord { + pending_count: claimable_pending_count, + delayed_pending_count: 0, + claimable_pending_count, + running_active_count, + expired_running_count, + terminal_count: 0, + claimable_count, + oldest_claimable_age_micros: None, + now_micros: 0, + } + } +} diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 015a510e..8200eab2 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -9,7 +9,11 @@ use module_assets::{ generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, @@ -20,7 +24,9 @@ use shared_contracts::jump_hop::{ JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, @@ -49,6 +55,7 @@ use crate::{ }; const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; +pub(crate) const JUMP_HOP_COMPILE_DRAFT_JOB_KIND: &str = "jump_hop_compile_draft"; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -72,6 +79,14 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024"; const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JumpHopCompileDraftWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub payload: JumpHopActionRequest, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { tile_type: JumpHopTileType, @@ -174,6 +189,37 @@ pub async fn execute_jump_hop_action( let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); + let should_queue_generation = matches!( + payload.action_type, + JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_jump_hop_generation_queued( + session_id.clone(), + owner_user_id.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + let queue_job = enqueue_jump_hop_compile_draft_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_jump_hop_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } let generation_points_cost = if is_compile_draft { resolve_jump_hop_generation_points_cost(&state).await } else { @@ -246,6 +292,99 @@ pub async fn execute_jump_hop_action( } } +async fn enqueue_jump_hop_compile_draft_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: JumpHopActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + payload, + }) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("跳一跳 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("jump-hop:compile-draft:{session_id}:{job_id}"), + job_id, + job_kind: JUMP_HOP_COMPILE_DRAFT_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "jump-hop".to_string(), + source_entity_id: session_id.to_string(), + request_label: "跳一跳草稿生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + }) +} + +fn map_jump_hop_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +pub(crate) async fn execute_jump_hop_compile_draft_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: JumpHopCompileDraftWorkerPayload, +) -> Result { + maybe_generate_jump_hop_assets( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + &mut worker_payload.payload, + ) + .await?; + let response = state + .spacetime_client() + .execute_jump_hop_action( + worker_payload.session_id, + worker_payload.owner_user_id, + worker_payload.payload, + ) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + Ok(response.session) +} + async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, @@ -1005,15 +1144,8 @@ fn slice_jump_hop_tile_atlas( let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let tile_width = x1.saturating_sub(x0).max(1); let tile_height = y1.saturating_sub(y0).max(1); - let faces = slice_jump_hop_tile_uv_faces( - &source, - x0, - y0, - tile_width, - tile_height, - row, - col, - )?; + let faces = + slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), @@ -1043,22 +1175,70 @@ fn slice_jump_hop_tile_uv_faces( Ok(JumpHopTileFaceSlices { top: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Top, + 1, + 0, )?, front: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Front, + 1, + 1, )?, right: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Right, + 2, + 1, )?, back: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Back, + 3, + 1, )?, left: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Left, + 0, + 1, )?, bottom: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Bottom, + 1, + 2, )?, }) } @@ -1095,12 +1275,7 @@ fn slice_jump_hop_tile_uv_face( Ok(JumpHopTileFaceSlice { face, - source_atlas_cell: format!( - "row-{}-col-{}/{}", - atlas_row + 1, - atlas_col + 1, - face_label - ), + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label), bytes: cursor.into_inner(), }) } @@ -1827,7 +2002,9 @@ mod tests { assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); assert!(prompt.contains("按三列六行均匀排布")); assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); - assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")); + assert!( + prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上") + ); assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集")); assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); assert!(prompt.contains("游戏界面或图标集页面")); @@ -1850,7 +2027,9 @@ mod tests { assert!(prompt.contains("full-bleed opaque square face texture")); assert!(prompt.contains("四角、边缘和中心都要有可识别内容")); assert!(prompt.contains("不留透明、不留空白、不留实底背景")); - assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点")); + assert!(prompt.contains( + "允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点" + )); assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央")); assert!(prompt.contains("这不是透视渲染图")); assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁")); @@ -1868,14 +2047,18 @@ mod tests { assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理")); assert!(prompt.contains("English guardrail")); assert!(prompt.contains("one vertical 1024x1536 image")); - assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); + assert!( + prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas") + ); assert!(prompt.contains("row1 col2 top")); assert!(prompt.contains("row2 col1 left")); assert!(prompt.contains("row2 col2 front")); assert!(prompt.contains("row2 col3 right")); assert!(prompt.contains("row2 col4 back")); assert!(prompt.contains("row3 col2 bottom")); - assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); + assert!(prompt.contains( + "six different face textures that stitch into one recognizable cubified theme object" + )); assert!(prompt.contains("no generic flat material")); assert!(prompt.contains("no small centered stickers")); assert!(prompt.contains("every face is full-bleed opaque square texture")); @@ -2022,7 +2205,9 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); + assert!( + prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图") + ); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); @@ -2118,12 +2303,10 @@ mod tests { .max(1); let tile_x = atlas_col.saturating_mul(cell_width); let tile_y = atlas_row.saturating_mul(cell_height); - let uv_x = tile_x.saturating_add( - cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, - ); - let uv_y = tile_y.saturating_add( - cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2, - ); + let uv_x = tile_x + .saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2); + let uv_y = tile_y + .saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2); for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side { for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side { atlas.put_pixel(x, y, color); @@ -2159,14 +2342,8 @@ mod tests { ), "{message}" ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == color), - "{message}" - ); - assert!( - decoded.pixels().all(|pixel| pixel.0[3] == 255), - "{message}" - ); + assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}"); + assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}"); } #[test] diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index f5954ba3..ec6d4451 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -41,6 +41,9 @@ mod edutainment_baby_drawing; mod edutainment_baby_object; mod error_middleware; mod external_api_audit; +mod external_generation; +mod external_generation_worker; +mod external_generation_worker_controller; pub(crate) mod generated_asset_sheets; mod generated_image_assets; mod health; @@ -115,6 +118,8 @@ use tracing::{error, info, warn}; use crate::{ app::{build_router, build_spacetime_unavailable_router}, config::AppConfig, + external_generation_worker::run_external_generation_worker, + external_generation_worker_controller::run_external_generation_worker_controller, state::{AppState, AppStateInitError}, tracking_outbox::TrackingOutbox, wallet_refund_outbox::WalletRefundOutbox, @@ -168,24 +173,57 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { process_metrics::register_process_metrics(); telemetry::register_http_runtime_metrics(); + if !config.process_role.runs_http() { + return run_worker_only(config).await; + } + + run_http_role(config).await +} + +async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> { + let process_role = config.process_role; + let state = restore_app_state_for_startup(config) + .await + .map_err(|error| { + io::Error::other(format!( + "初始化 external generation worker 状态失败:{error}" + )) + })?; + spawn_app_state_background_workers(&state); + info!( + process_role = process_role.as_str(), + "api-server 以非 HTTP 角色启动" + ); + if process_role.runs_external_generation_worker() { + run_external_generation_worker(state).await + } else if process_role.runs_external_generation_controller() { + run_external_generation_worker_controller(state).await + } else { + Err(io::Error::other(format!( + "不支持的非 HTTP 进程角色:{}", + process_role.as_str() + ))) + } +} + +async fn run_http_role(config: AppConfig) -> Result<(), io::Error> { let bind_address = config.bind_socket_addr(); let listen_backlog = config.listen_backlog; let worker_threads = config.worker_threads; let otel_enabled = config.otel_enabled; + let process_role = config.process_role; let outbox_flush_timeout = config.shutdown_outbox_flush_timeout; let listener = build_tcp_listener(bind_address, listen_backlog)?; - let (router, shutdown_context) = match restore_app_state_for_startup(config).await { + let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await + { Ok(state) => { - state.puzzle_gallery_cache().spawn_cleanup_task(); + spawn_app_state_background_workers(&state); let tracking_outbox = state.tracking_outbox(); - if let Some(outbox) = tracking_outbox.clone() { - outbox.spawn_worker(); - } let wallet_refund_outbox = state.wallet_refund_outbox(); - if let Some(outbox) = wallet_refund_outbox.clone() { - outbox.spawn_worker(); - } + let worker_state = process_role + .runs_external_generation_worker() + .then(|| state.clone()); ( build_router(state.clone()), ShutdownContext { @@ -194,6 +232,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { wallet_refund_outbox, outbox_flush_timeout, }, + worker_state, ) } Err(AppStateInitError::DependencyUnavailable(message)) => ( @@ -204,6 +243,7 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { wallet_refund_outbox: None, outbox_flush_timeout, }, + None, ), Err(error) => { return Err(std::io::Error::other(format!( @@ -217,12 +257,20 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { listen_backlog, worker_threads = worker_threads.unwrap_or(0), otel_enabled, + process_role = process_role.as_str(), "api-server 已完成 tracing 初始化并开始监听" ); - let result = axum::serve(listener, router) - .with_graceful_shutdown(shutdown_signal(shutdown_context.clone())) - .await; + let http_server = axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal(shutdown_context.clone())); + let result = if let Some(worker_state) = worker_state { + tokio::select! { + result = http_server => result, + result = run_external_generation_worker(worker_state) => result, + } + } else { + http_server.await + }; finalize_shutdown(shutdown_context).await; result } @@ -333,6 +381,16 @@ async fn finalize_shutdown(context: ShutdownContext) { } } +fn spawn_app_state_background_workers(state: &AppState) { + state.puzzle_gallery_cache().spawn_cleanup_task(); + if let Some(outbox) = state.tracking_outbox() { + outbox.spawn_worker(); + } + if let Some(outbox) = state.wallet_refund_outbox() { + outbox.spawn_worker(); + } +} + fn build_tcp_listener( bind_address: SocketAddr, listen_backlog: i32, diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 6f040e3f..c2c71344 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -6,6 +6,7 @@ pub mod big_fish; pub mod custom_world; pub mod editor_project; pub mod edutainment; +pub mod external_generation; pub mod health; pub mod internal; pub mod jump_hop; diff --git a/server-rs/crates/api-server/src/modules/external_generation.rs b/server-rs/crates/api-server/src/modules/external_generation.rs new file mode 100644 index 00000000..b65c832d --- /dev/null +++ b/server-rs/crates/api-server/src/modules/external_generation.rs @@ -0,0 +1,26 @@ +use axum::{Router, middleware, routing::get}; + +use crate::{ + auth::require_bearer_auth, + external_generation::{ + get_external_generation_job_status, get_external_generation_queue_overview, + }, + state::AppState, +}; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/runtime/external-generation/queue-overview", + get(get_external_generation_queue_overview).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/external-generation/jobs/{job_id}", + get(get_external_generation_job_status).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 6581676c..cc9d0237 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -52,22 +52,22 @@ use shared_contracts::{ }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::{ - PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, - PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, - PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, + ExternalGenerationJobEnqueueRecordInput, PuzzleAgentMessageRecord, + PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, + PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBackgroundCompileTaskClaimRecordInput, PuzzleBackgroundCompileTaskReleaseRecordInput, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, - PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput, + PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleSelectCoverImageRecordInput, + PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; @@ -79,6 +79,10 @@ use crate::{ should_skip_asset_operation_billing_for_connectivity, }, auth::{AuthenticatedAccessToken, RuntimePrincipal}, + external_generation_worker::{ + PUZZLE_COMPILE_DRAFT_JOB_KIND, PUZZLE_GENERATE_IMAGES_JOB_KIND, + PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND, + }, generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, llm_model_routing::{CREATION_TEMPLATE_LLM_MODEL, PUZZLE_LEVEL_NAME_VISION_LLM_MODEL}, @@ -185,6 +189,25 @@ async fn release_claimed_puzzle_background_compile_task( } } +pub(crate) fn spawn_release_claimed_puzzle_background_compile_task( + state: PuzzleApiState, + task_id: String, + claim_id: String, + session_id: String, + owner_user_id: String, +) { + tokio::spawn(async move { + release_claimed_puzzle_background_compile_task( + &state, + &task_id, + &claim_id, + &session_id, + &owner_user_id, + ) + .await; + }); +} + fn has_puzzle_cover_image_src(value: &Option) -> bool { value .as_deref() @@ -215,6 +238,65 @@ fn mark_puzzle_initial_generation_started_snapshot( session } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExternalGenerationWriteLeaseGuard { + pub(crate) job_id: Option, + pub(crate) worker_id: Option, + pub(crate) lease_token: Option, +} + +impl ExternalGenerationWriteLeaseGuard { + pub(crate) fn inline() -> Self { + Self { + job_id: None, + worker_id: None, + lease_token: None, + } + } + + pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self { + Self { + job_id: Some(job_id), + worker_id: Some(worker_id), + lease_token: Some(lease_token), + } + } +} + +#[derive(Debug)] +pub(crate) struct PuzzleExternalGenerationWorkerError { + error: AppError, + should_fail_queue_job: bool, +} + +impl PuzzleExternalGenerationWorkerError { + pub(crate) fn with_failure_state_written(error: AppError) -> Self { + Self { + error, + should_fail_queue_job: true, + } + } + + pub(crate) fn with_failure_state_pending(error: AppError) -> Self { + Self { + error, + should_fail_queue_job: false, + } + } + + pub(crate) fn body_text(&self) -> String { + self.error.body_text() + } + + pub(crate) fn into_app_error(self) -> AppError { + self.error + } + + pub(crate) fn should_fail_queue_job(&self) -> bool { + self.should_fail_queue_job + } +} + pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String { format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0) } @@ -237,7 +319,7 @@ mod mappers; use self::mappers::*; mod draft; -use self::draft::*; +pub(crate) use self::draft::*; mod tags; @@ -246,7 +328,7 @@ use self::tags::*; mod generation; mod vector_engine; -use self::generation::*; +pub(crate) use self::generation::*; use self::vector_engine::*; #[cfg(test)] diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index ddb1d6ea..0f6e949e 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -137,6 +137,213 @@ pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing( Ok(replacement.session_id) } +fn default_puzzle_image_generation_points_cost() -> u64 { + PUZZLE_IMAGE_GENERATION_POINTS_COST +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PuzzleCompileDraftWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub billing_asset_id: String, + pub ai_redraw: bool, + #[serde(default = "default_puzzle_image_generation_points_cost")] + pub billing_points_cost: u64, + #[serde(default)] + pub prompt_text: Option, + #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] + pub image_model: Option, + pub requested_at_micros: i64, + #[serde(default)] + pub background_task_id: Option, + #[serde(default)] + pub background_claim_id: Option, +} + +pub(crate) async fn execute_puzzle_compile_draft_worker_job( + state: &PuzzleApiState, + request_context: &RequestContext, + payload: PuzzleCompileDraftWorkerPayload, + external_generation_guard: ExternalGenerationWriteLeaseGuard, +) -> Result { + let now = current_utc_micros(); + let session = if payload.ai_redraw { + execute_billable_asset_operation_with_cost( + state.root_state(), + &payload.owner_user_id, + "puzzle_initial_image", + &payload.billing_asset_id, + payload.billing_points_cost, + async { + compile_puzzle_draft_with_initial_cover( + state, + request_context, + payload.session_id.clone(), + payload.owner_user_id.clone(), + payload.prompt_text.as_deref(), + payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), + now, + &external_generation_guard, + ) + .await + }, + ) + .await + } else { + compile_puzzle_draft_with_uploaded_cover( + state, + request_context, + payload.session_id.clone(), + payload.owner_user_id.clone(), + payload.prompt_text.as_deref(), + payload.reference_image_src.as_deref(), + now, + &external_generation_guard, + ) + .await + }; + + match session { + Ok(session) => { + if session + .draft + .as_ref() + .is_some_and(|draft| draft.generation_status == "ready") + { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id: payload.owner_user_id.clone(), + task_name: Some("拼图".to_string()), + work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), + status: GenerationResultSubscribeMessageStatus::Succeeded, + consumed_points: if payload.ai_redraw { + payload.billing_points_cost + } else { + 0 + }, + completed_at_micros: current_utc_micros(), + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + } + release_inline_puzzle_compile_background_claim( + state, + &payload, + &external_generation_guard, + ); + Ok(session) + } + Err(error) => { + match mark_puzzle_compile_failure_for_worker( + state, + &payload.session_id, + &payload.owner_user_id, + error.body_text(), + now, + &external_generation_guard, + ) + .await + { + Ok(()) => { + send_generation_result_subscribe_message_after_completion( + state.root_state(), + GenerationResultSubscribeMessage { + owner_user_id: payload.owner_user_id.clone(), + task_name: Some("拼图".to_string()), + work_name: None, + status: GenerationResultSubscribeMessageStatus::Failed, + consumed_points: 0, + completed_at_micros: now, + page: Some("/pages/web-view/index".to_string()), + }, + ) + .await; + release_inline_puzzle_compile_background_claim( + state, + &payload, + &external_generation_guard, + ); + Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error)) + } + Err(mark_error) => { + Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error)) + } + } + } + } +} + +fn release_inline_puzzle_compile_background_claim( + state: &PuzzleApiState, + payload: &PuzzleCompileDraftWorkerPayload, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) { + if external_generation_guard.job_id.is_some() { + return; + } + release_puzzle_compile_background_claim(state, payload); +} + +pub(crate) fn release_puzzle_compile_background_claim( + state: &PuzzleApiState, + payload: &PuzzleCompileDraftWorkerPayload, +) { + let (Some(task_id), Some(claim_id)) = ( + payload.background_task_id.as_ref(), + payload.background_claim_id.as_ref(), + ) else { + return; + }; + + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.clone(), + claim_id.clone(), + payload.session_id.clone(), + payload.owner_user_id.clone(), + ); +} + +pub(crate) async fn mark_puzzle_compile_failure_for_worker( + state: &PuzzleApiState, + session_id: &str, + owner_user_id: &str, + error_message: String, + failed_at_micros: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result<(), AppError> { + let result = state + .spacetime_client() + .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + error_message, + failed_at_micros, + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), + }) + .await; + if let Err(error) = result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + owner_user_id, + message = %error, + "拼图 worker 草稿失败态回写失败" + ); + return Err(map_puzzle_client_error(error)); + } + + Ok(()) +} + pub(crate) fn select_puzzle_level_for_api( draft: &PuzzleResultDraftRecord, level_id: Option<&str>, @@ -1163,6 +1370,44 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>( .or_else(|| levels.first()) } +pub(crate) async fn compile_puzzle_draft_with_initial_cover( + state: &PuzzleApiState, + request_context: &RequestContext, + session_id: String, + owner_user_id: String, + prompt_text: Option<&str>, + reference_image_src: Option<&str>, + image_model: Option<&str>, + now: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result { + let compiled_session = state + .spacetime_client() + .compile_puzzle_agent_draft_with_external_generation_guard( + session_id, + owner_user_id.clone(), + now, + external_generation_guard.job_id.clone(), + external_generation_guard.worker_id.clone(), + external_generation_guard.lease_token.clone(), + ) + .await + .map_err(map_puzzle_compile_error)?; + + generate_puzzle_initial_cover_from_compiled_session( + state, + request_context, + compiled_session, + owner_user_id, + prompt_text, + reference_image_src, + image_model, + now, + external_generation_guard, + ) + .await +} + pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session( state: &PuzzleApiState, request_context: &RequestContext, @@ -1172,6 +1417,7 @@ pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session( reference_image_src: Option<&str>, image_model: Option<&str>, now: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, ) -> Result { let draft = compiled_session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -1322,6 +1568,9 @@ pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session( levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), }) .await .map_err(map_puzzle_client_error) @@ -1435,6 +1684,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( prompt_text: Option<&str>, reference_image_src: Option<&str>, now: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, ) -> Result { let uploaded_image_src = reference_image_src .map(str::trim) @@ -1469,7 +1719,14 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( })?; let compiled_session = state .spacetime_client() - .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) + .compile_puzzle_agent_draft_with_external_generation_guard( + session_id.clone(), + owner_user_id.clone(), + now, + external_generation_guard.job_id.clone(), + external_generation_guard.worker_id.clone(), + external_generation_guard.lease_token.clone(), + ) .await .map_err(map_puzzle_compile_error)?; let draft = compiled_session.draft.clone().ok_or_else(|| { @@ -1618,6 +1875,9 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover( levels_json: levels_json_with_generated_name.clone(), candidates_json, saved_at_micros: current_utc_micros(), + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), }) .await .map_err(map_puzzle_client_error) diff --git a/server-rs/crates/api-server/src/puzzle/generation.rs b/server-rs/crates/api-server/src/puzzle/generation.rs index 3713a653..091ec04e 100644 --- a/server-rs/crates/api-server/src/puzzle/generation.rs +++ b/server-rs/crates/api-server/src/puzzle/generation.rs @@ -22,6 +22,510 @@ pub(crate) fn should_use_uploaded_puzzle_image_directly( .is_some_and(|value| !value.is_empty()) } +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PuzzleGenerateImagesWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub billing_asset_id: String, + #[serde(default)] + pub level_id: Option, + #[serde(default)] + pub prompt_text: Option, + #[serde(default)] + pub reference_image_src: Option, + #[serde(default)] + pub reference_image_srcs: Vec, + #[serde(default)] + pub reference_image_asset_object_id: Option, + #[serde(default)] + pub reference_image_asset_object_ids: Vec, + #[serde(default)] + pub image_model: Option, + #[serde(default)] + pub ai_redraw: Option, + #[serde(default)] + pub should_auto_name_level: Option, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub theme_tags: Option>, + #[serde(default)] + pub levels_json: Option, + pub requested_at_micros: i64, +} + +impl PuzzleGenerateImagesWorkerPayload { + fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest { + ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: self.prompt_text.clone(), + reference_image_src: self.reference_image_src.clone(), + reference_image_srcs: self.reference_image_srcs.clone(), + reference_image_asset_object_id: self.reference_image_asset_object_id.clone(), + reference_image_asset_object_ids: self.reference_image_asset_object_ids.clone(), + image_model: self.image_model.clone(), + ai_redraw: self.ai_redraw, + candidate_count: Some(1), + should_auto_name_level: self.should_auto_name_level, + candidate_id: None, + level_id: self.level_id.clone(), + work_title: self.work_title.clone(), + work_description: self.work_description.clone(), + picture_description: self.picture_description.clone(), + level_name: None, + summary: self.summary.clone(), + theme_tags: self.theme_tags.clone(), + levels_json: self.levels_json.clone(), + } + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PuzzleGenerateUiBackgroundWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub billing_asset_id: String, + #[serde(default)] + pub level_id: Option, + #[serde(default)] + pub prompt_text: Option, + #[serde(default)] + pub levels_json: Option, + pub requested_at_micros: i64, +} + +impl PuzzleGenerateUiBackgroundWorkerPayload { + fn to_action_request(&self) -> ExecutePuzzleAgentActionRequest { + ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_ui_background".to_string(), + prompt_text: self.prompt_text.clone(), + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: None, + ai_redraw: None, + candidate_count: None, + should_auto_name_level: None, + candidate_id: None, + level_id: self.level_id.clone(), + work_title: None, + work_description: None, + picture_description: None, + level_name: None, + summary: None, + theme_tags: None, + levels_json: self.levels_json.clone(), + } + } +} + +pub(crate) async fn execute_puzzle_generate_images_worker_job( + state: &PuzzleApiState, + request_context: &RequestContext, + payload: PuzzleGenerateImagesWorkerPayload, + external_generation_guard: ExternalGenerationWriteLeaseGuard, +) -> Result { + let now = current_utc_micros(); + let session = execute_billable_asset_operation_with_cost( + state.root_state(), + &payload.owner_user_id, + "puzzle_generated_image", + &payload.billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + execute_puzzle_generate_images_worker_job_inner( + state, + request_context, + &payload, + now, + &external_generation_guard, + ) + .await + }, + ) + .await; + + match session { + Ok(session) => Ok(session), + Err(error) => { + match mark_puzzle_level_generation_failure_for_worker( + state, + &payload, + error.body_text(), + now, + &external_generation_guard, + ) + .await + { + Ok(()) => { + Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error)) + } + Err(mark_error) => { + Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error)) + } + } + } + } +} + +pub(crate) async fn execute_puzzle_generate_ui_background_worker_job( + state: &PuzzleApiState, + request_context: &RequestContext, + payload: PuzzleGenerateUiBackgroundWorkerPayload, + external_generation_guard: ExternalGenerationWriteLeaseGuard, +) -> Result { + let now = current_utc_micros(); + let session = execute_billable_asset_operation_with_cost( + state.root_state(), + &payload.owner_user_id, + "puzzle_ui_background_image", + &payload.billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, + async { + execute_puzzle_generate_ui_background_worker_job_inner( + state, + request_context, + &payload, + now, + &external_generation_guard, + ) + .await + }, + ) + .await; + + match session { + Ok(session) => Ok(session), + Err(error) => { + match mark_puzzle_level_generation_failure_for_external_generation( + state, + &payload.session_id, + &payload.owner_user_id, + payload.level_id.clone(), + payload.levels_json.clone(), + error.body_text(), + now, + &external_generation_guard, + ) + .await + { + Ok(()) => { + Err(PuzzleExternalGenerationWorkerError::with_failure_state_written(error)) + } + Err(mark_error) => { + Err(PuzzleExternalGenerationWorkerError::with_failure_state_pending(mark_error)) + } + } + } + } +} + +async fn execute_puzzle_generate_images_worker_job_inner( + state: &PuzzleApiState, + request_context: &RequestContext, + payload: &PuzzleGenerateImagesWorkerPayload, + now: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result { + let action_payload = payload.to_action_request(); + let target_level_id = payload.level_id.clone(); + let levels_json = payload.levels_json.clone(); + let session = get_puzzle_session_for_image_generation( + state, + payload.session_id.clone(), + payload.owner_user_id.clone(), + &action_payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let mut target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let prompt = resolve_puzzle_level_image_prompt( + payload.prompt_text.as_deref(), + &target_level.picture_description, + &draft.summary, + ); + let should_auto_name_level = payload + .should_auto_name_level + .unwrap_or_else(|| target_level.level_name.trim().is_empty()); + if should_auto_name_level { + let naming = + generate_puzzle_first_level_name(state, target_level.picture_description.as_str()) + .await; + target_level.level_name = naming.level_name.clone(); + target_level.ui_background_prompt = naming.ui_background_prompt.clone(); + } + let reference_image_sources = collect_puzzle_reference_image_sources( + payload.reference_image_src.as_deref(), + payload.reference_image_srcs.as_slice(), + payload.reference_image_asset_object_id.as_deref(), + payload.reference_image_asset_object_ids.as_slice(), + ); + let primary_reference_image_src = reference_image_sources.first().map(String::as_str); + // 中文注释:拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 + let candidate_start_index = target_level.candidates.len(); + let ai_redraw = payload.ai_redraw.unwrap_or(true); + let mut candidates = + if should_use_uploaded_puzzle_image_directly(primary_reference_image_src, ai_redraw) { + vec![ + create_uploaded_puzzle_image_candidate( + state, + payload.owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src.expect("checked reference image"), + candidate_start_index, + ) + .await?, + ] + } else { + let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id); + generate_puzzle_image_candidates( + state, + payload.owner_user_id.as_str(), + Some(profile_id.as_str()), + &session.session_id, + &target_level.level_name, + &prompt, + primary_reference_image_src, + ai_redraw, + payload.image_model.as_deref(), + 1, + candidate_start_index, + ) + .await + .map_err(map_puzzle_generation_endpoint_error)? + }; + if candidates.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })), + ); + } + if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( + state, + target_level.picture_description.as_str(), + &candidates[0].downloaded_image, + ) + .await + .filter(|_| should_auto_name_level) + { + target_level.level_name = refined_naming.level_name.clone(); + if refined_naming.ui_background_prompt.is_some() { + target_level.ui_background_prompt = refined_naming.ui_background_prompt.clone(); + } + } + let mut updated_levels = + build_puzzle_levels_with_primary_update(&draft, &target_level, primary_reference_image_src); + for candidate in &mut candidates { + candidate.record.prompt = prompt.clone(); + } + let selected_candidate = candidates + .iter() + .find(|candidate| candidate.record.selected) + .or_else(|| candidates.first()) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; + let asset_bundle = generate_puzzle_level_asset_bundle_required( + state, + request_context, + payload.owner_user_id.as_str(), + &session.session_id, + &target_level, + &selected_candidate.downloaded_image, + ) + .await?; + attach_puzzle_level_asset_bundle( + &mut updated_levels, + target_level.level_id.as_str(), + asset_bundle, + ); + attach_selected_puzzle_candidate_to_levels( + &mut updated_levels, + target_level.level_id.as_str(), + &selected_candidate.record, + ); + let levels_json_with_generated_name = + Some(serialize_puzzle_level_records_for_module(&updated_levels)?); + let candidates_json = serde_json::to_string( + &candidates + .iter() + .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) + .collect::>(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + state + .spacetime_client() + .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: payload.owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: levels_json_with_generated_name, + candidates_json, + saved_at_micros: now, + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), + }) + .await + .map_err(map_puzzle_client_error) +} + +async fn execute_puzzle_generate_ui_background_worker_job_inner( + state: &PuzzleApiState, + request_context: &RequestContext, + payload: &PuzzleGenerateUiBackgroundWorkerPayload, + now: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result { + let action_payload = payload.to_action_request(); + let target_level_id = payload.level_id.clone(); + let levels_json = payload.levels_json.clone(); + let session = get_puzzle_session_for_image_generation( + state, + payload.session_id.clone(), + payload.owner_user_id.clone(), + &action_payload, + levels_json.as_deref(), + now, + ) + .await?; + let mut draft = session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let target_level = select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let raw_prompt = payload + .prompt_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(); + let resolved_prompt = + normalize_puzzle_ui_background_prompt(raw_prompt.as_str(), &draft, &target_level); + let generated = generate_puzzle_ui_background_image( + state, + request_context, + payload.owner_user_id.as_str(), + &session.session_id, + &target_level.level_name, + resolved_prompt.as_str(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; + state + .spacetime_client() + .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { + session_id: session.session_id.clone(), + owner_user_id: payload.owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json, + prompt: resolved_prompt.clone(), + image_src: generated.image_src.clone(), + image_object_key: Some(generated.object_key.clone()), + saved_at_micros: now, + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), + }) + .await + .map_err(map_puzzle_client_error) +} + +pub(crate) async fn mark_puzzle_level_generation_failure_for_worker( + state: &PuzzleApiState, + payload: &PuzzleGenerateImagesWorkerPayload, + error_message: String, + failed_at_micros: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result<(), AppError> { + mark_puzzle_level_generation_failure_for_external_generation( + state, + &payload.session_id, + &payload.owner_user_id, + payload.level_id.clone(), + payload.levels_json.clone(), + error_message, + failed_at_micros, + external_generation_guard, + ) + .await +} + +async fn mark_puzzle_level_generation_failure_for_external_generation( + state: &PuzzleApiState, + session_id: &str, + owner_user_id: &str, + level_id: Option, + levels_json: Option, + error_message: String, + failed_at_micros: i64, + external_generation_guard: &ExternalGenerationWriteLeaseGuard, +) -> Result<(), AppError> { + let result = state + .spacetime_client() + .mark_puzzle_level_generation_failed(PuzzleLevelGenerationFailureRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + level_id, + levels_json, + error_message, + failed_at_micros, + external_generation_job_id: external_generation_guard.job_id.clone(), + external_generation_worker_id: external_generation_guard.worker_id.clone(), + external_generation_lease_token: external_generation_guard.lease_token.clone(), + }) + .await; + if let Err(error) = result { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + message = %error, + "拼图 worker 关卡生图失败态回写失败" + ); + return Err(map_puzzle_client_error(error)); + } + + Ok(()) +} + pub(crate) async fn create_uploaded_puzzle_image_candidate( state: &PuzzleApiState, owner_user_id: &str, diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index d7a2b0bd..e5e24dda 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -589,7 +589,6 @@ pub async fn execute_puzzle_agent_action( let now = current_utc_micros(); let action = payload.action.trim().to_string(); let billing_asset_id = format!("{}:{}:{}", session_id, action, request_context.request_id()); - let mut operation_consumed_points = 0; tracing::info!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, @@ -610,50 +609,6 @@ pub async fn execute_puzzle_agent_action( "拼图 Agent action 开始执行" ); - let mark_puzzle_compile_failure = |error: &AppError, compile_session_id: &str| { - let state = state.clone(); - let owner_user_id = owner_user_id.clone(); - let error_message = error.body_text(); - let session_id = compile_session_id.to_string(); - let log_session_id = session_id.clone(); - let log_owner_user_id = owner_user_id.clone(); - async move { - let failed_at_micros = current_utc_micros(); - let result = state - .spacetime_client() - .mark_puzzle_draft_generation_failed(PuzzleDraftCompileFailureRecordInput { - session_id, - owner_user_id: owner_user_id.clone(), - error_message, - failed_at_micros, - }) - .await; - if let Err(error) = result { - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %log_session_id, - owner_user_id = %log_owner_user_id, - message = %error, - "拼图草稿失败态回写失败,继续返回原始错误" - ); - } else { - send_generation_result_subscribe_message_after_completion( - state.root_state(), - GenerationResultSubscribeMessage { - owner_user_id, - task_name: Some("拼图".to_string()), - work_name: None, - status: GenerationResultSubscribeMessageStatus::Failed, - consumed_points: 0, - completed_at_micros: failed_at_micros, - page: Some("/pages/web-view/index".to_string()), - }, - ) - .await; - } - } - }; - let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { let ai_redraw = payload.ai_redraw.unwrap_or(true); @@ -667,7 +622,6 @@ pub async fn execute_puzzle_agent_action( } else { 0 }; - operation_consumed_points = puzzle_draft_generation_points_cost; let reference_image_sources = collect_puzzle_reference_image_sources( payload.reference_image_src.as_deref(), payload.reference_image_srcs.as_slice(), @@ -694,7 +648,20 @@ pub async fn execute_puzzle_agent_action( Ok(next_session_id) => next_session_id, Err(response) => return Err(response), }; - let session = if ai_redraw { + let worker_payload = PuzzleCompileDraftWorkerPayload { + session_id: compile_session_id.clone(), + owner_user_id: owner_user_id.clone(), + billing_asset_id: billing_asset_id.clone(), + ai_redraw, + billing_points_cost: puzzle_draft_generation_points_cost, + prompt_text: prompt_text.map(ToOwned::to_owned), + reference_image_src: primary_reference_image_src.map(ToOwned::to_owned), + image_model: payload.image_model.clone(), + requested_at_micros: now, + background_task_id: None, + background_claim_id: None, + }; + let worker_payload = if ai_redraw { let background_task_id = build_puzzle_background_compile_task_id(&compile_session_id); let background_claim_id = build_puzzle_background_compile_claim_id( @@ -713,223 +680,202 @@ pub async fn execute_puzzle_agent_action( }, ) .await - .map_err(map_puzzle_client_error); - match claim_result { - Ok(false) => { - tracing::info!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %compile_session_id, - owner_user_id = %owner_user_id, - task_id = %background_task_id, - "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" - ); - state - .spacetime_client() - .get_puzzle_agent_session( - compile_session_id.clone(), - owner_user_id.clone(), + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !claim_result { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + task_id = %background_task_id, + "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话" + ); + let session = state + .spacetime_client() + .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) + .await + .map(mark_puzzle_initial_generation_started_snapshot) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), ) - .await - .map(mark_puzzle_initial_generation_started_snapshot) - .map_err(map_puzzle_client_error) - } - Ok(true) => { - let compiled_session = state - .spacetime_client() - .compile_puzzle_agent_draft( - compile_session_id.clone(), - owner_user_id.clone(), - now, - ) - .await - .map_err(map_puzzle_compile_error); - match compiled_session { - Ok(compiled_session) => { - let response_session = - mark_puzzle_initial_generation_started_snapshot( - compiled_session.clone(), - ); - let background_state = state.clone(); - let background_request_context = request_context.clone(); - let background_session_id = compile_session_id.clone(); - let background_owner_user_id = owner_user_id.clone(); - let background_task_id = background_task_id.clone(); - let background_claim_id = background_claim_id.clone(); - let background_prompt_text = prompt_text.map(str::to_string); - let background_reference_image_src = - primary_reference_image_src.map(str::to_string); - let background_image_model = payload.image_model.clone(); - let background_points_cost = puzzle_draft_generation_points_cost; - let background_work_name = compiled_session - .draft - .as_ref() - .map(|draft| draft.work_title.clone()); - let background_billing_asset_id = format!( - "{background_session_id}:compile_puzzle_draft:{}", - background_request_context.request_id() - ); - tokio::spawn(async move { - let operation_owner_user_id = background_owner_user_id.clone(); - let background_root_state = - background_state.root_state().clone(); - let operation_state = background_state.clone(); - let result = execute_billable_asset_operation_with_cost( - &background_root_state, - &background_owner_user_id, - "puzzle_initial_image", - &background_billing_asset_id, - background_points_cost, - async move { - generate_puzzle_initial_cover_from_compiled_session( - &operation_state, - &background_request_context, - compiled_session, - operation_owner_user_id, - background_prompt_text.as_deref(), - background_reference_image_src.as_deref(), - background_image_model.as_deref(), - current_utc_micros(), - ) - .await - }, - ) - .await; - match result { - Ok(session) => { - send_generation_result_subscribe_message_after_completion( - &background_root_state, - GenerationResultSubscribeMessage { - owner_user_id: background_owner_user_id.clone(), - task_name: Some("拼图".to_string()), - work_name: session - .draft - .as_ref() - .map(|draft| draft.work_title.clone()), - status: - GenerationResultSubscribeMessageStatus::Succeeded, - consumed_points: background_points_cost, - completed_at_micros: current_utc_micros(), - page: Some("/pages/web-view/index".to_string()), - }, - ) - .await; - tracing::info!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %background_owner_user_id, - "拼图首图后台生成任务完成" - ); - } - Err(error) => { - let error_message = error.body_text(); - let failed_at_micros = current_utc_micros(); - let failure_result = background_state - .spacetime_client() - .mark_puzzle_draft_generation_failed( - PuzzleDraftCompileFailureRecordInput { - session_id: background_session_id.clone(), - owner_user_id: background_owner_user_id - .clone(), - error_message: error_message.clone(), - failed_at_micros, - }, - ) - .await; - if let Err(mark_error) = failure_result { - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %background_session_id, - owner_user_id = %background_owner_user_id, - message = %mark_error, - "拼图首图后台生成失败态回写失败" - ); - } else { - send_generation_result_subscribe_message_after_completion( - &background_root_state, - GenerationResultSubscribeMessage { - owner_user_id: background_owner_user_id - .clone(), - task_name: Some("拼图".to_string()), - work_name: background_work_name.clone(), - status: - GenerationResultSubscribeMessageStatus::Failed, - consumed_points: 0, - completed_at_micros: failed_at_micros, - page: Some( - "/pages/web-view/index".to_string(), - ), - }, - ) - .await; - } - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %background_session_id, - owner_user_id = %background_owner_user_id, - message = %error_message, - "拼图首图后台生成任务失败" - ); - } - } - release_claimed_puzzle_background_compile_task( - &background_state, - &background_task_id, - &background_claim_id, - &background_session_id, - &background_owner_user_id, - ) - .await; - }); - Ok(response_session) - } - Err(error) => { - release_claimed_puzzle_background_compile_task( - &state, - &background_task_id, - &background_claim_id, - &compile_session_id, - &owner_user_id, - ) - .await; - Err(error) - } - } - } - Err(error) => Err(error), + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: background_task_id, + operation_type: "compile_puzzle_draft".to_string(), + status: "running".to_string(), + phase_label: "首关拼图草稿".to_string(), + phase_detail: "首关草稿生成已在后台处理中。".to_string(), + progress: session.progress_percent.max(10), + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + + PuzzleCompileDraftWorkerPayload { + background_task_id: Some(background_task_id), + background_claim_id: Some(background_claim_id), + ..worker_payload } } else { - compile_puzzle_draft_with_uploaded_cover( + worker_payload + }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compile_session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图首关草稿生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_compile_draft_worker_job( &state, &request_context, - compile_session_id.clone(), - owner_user_id.clone(), - prompt_text, - primary_reference_image_src, - now, + worker_payload.clone(), + ExternalGenerationWriteLeaseGuard::inline(), ) .await - }; - let session = match session { - Ok(session) => Ok(session), - Err(error) => { - mark_puzzle_compile_failure(&error, &compile_session_id).await; - Err(puzzle_error_response( + .map_err(|error| { + puzzle_error_response( &request_context, PUZZLE_AGENT_API_BASE_PROVIDER, - error, - )) + error.into_app_error(), + ) + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "compile_puzzle_draft".to_string(), + status: "completed".to_string(), + phase_label: "首关拼图草稿".to_string(), + phase_detail: if ai_redraw { + "首关草稿生成已完成。".to_string() + } else { + "首关草稿编译已完成。".to_string() + }, + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { + if let (Some(task_id), Some(claim_id)) = ( + worker_payload.background_task_id.as_deref(), + worker_payload.background_claim_id.as_deref(), + ) { + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.to_string(), + claim_id.to_string(), + compile_session_id.clone(), + owner_user_id.clone(), + ); } + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图生成任务参数序列化失败:{error}"), + })), + ) + })?; + let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let job = state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + job_id: external_generation_job_id.clone(), + dedupe_key: format!( + "puzzle:compile_puzzle_draft:{compile_session_id}:{external_generation_job_id}" + ), + job_kind: PUZZLE_COMPILE_DRAFT_JOB_KIND.to_string(), + owner_user_id: owner_user_id.clone(), + source_module: "puzzle".to_string(), + source_entity_id: compile_session_id.clone(), + request_label: "拼图首关草稿生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now, + created_at_micros: now, + }) + .await + .map_err(|error| { + if let (Some(task_id), Some(claim_id)) = ( + worker_payload.background_task_id.as_deref(), + worker_payload.background_claim_id.as_deref(), + ) { + spawn_release_claimed_puzzle_background_compile_task( + state.clone(), + task_id.to_string(), + claim_id.to_string(), + compile_session_id.clone(), + owner_user_id.clone(), + ); + } + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let session = state + .spacetime_client() + .get_puzzle_agent_session(compile_session_id.clone(), owner_user_id.clone()) + .await + .map(mark_puzzle_initial_generation_started_snapshot) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let (status, progress) = match job.status.as_str() { + "completed" => ("completed", 100), + "running" => ("running", session.progress_percent.max(10)), + "failed" => ("failed", session.progress_percent), + _ => ("queued", session.progress_percent.max(5)), }; - ( - "compile_puzzle_draft", - "首关拼图草稿", - if ai_redraw { - "已编译首关草稿,并启动首关画面和 UI 资产后台生成。" - } else { - "已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。" + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: job.job_id, + operation_type: "compile_puzzle_draft".to_string(), + status: status.to_string(), + phase_label: "首关拼图草稿".to_string(), + phase_detail: if ai_redraw { + "首关草稿生成已进入后台队列。".to_string() + } else { + "首关草稿编译已进入后台队列。".to_string() + }, + progress, + error: job.last_error_message, + }, + session: map_puzzle_agent_session_response(session), }, - session, - ) + )); } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( @@ -991,367 +937,291 @@ pub async fn execute_puzzle_agent_action( payload.levels_json.as_deref(), ) .map_err(|message| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })) - }); - let session = execute_billable_asset_operation_with_cost( - state.root_state(), - &owner_user_id, - "puzzle_generated_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - let levels_json = levels_json?; - let session = get_puzzle_session_for_image_generation( - &state, - session_id.clone(), - owner_user_id.clone(), - &payload, - levels_json.as_deref(), - now, - ) - .await?; - let mut draft = session.draft.clone().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图结果页草稿尚未生成", - })) - })?; - if let Some(levels_json) = levels_json.as_ref() { - draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; - } - let mut target_level = - select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; - let fallback_level_name = target_level.level_name.clone(); - let prompt = resolve_puzzle_level_image_prompt( - payload.prompt_text.as_deref(), - &target_level.picture_description, - &draft.summary, - ); - let should_auto_name_level = payload - .should_auto_name_level - .unwrap_or_else(|| target_level.level_name.trim().is_empty()); - let mut generated_naming = if should_auto_name_level { - let naming = generate_puzzle_first_level_name( - &state, - target_level.picture_description.as_str(), - ) - .await; - target_level.level_name = naming.level_name.clone(); - target_level.ui_background_prompt = naming.ui_background_prompt.clone(); - Some(naming) - } else { - None - }; - let reference_image_sources = collect_puzzle_reference_image_sources( - payload.reference_image_src.as_deref(), - payload.reference_image_srcs.as_slice(), - payload.reference_image_asset_object_id.as_deref(), - payload.reference_image_asset_object_ids.as_slice(), - ); - let primary_reference_image_src = - reference_image_sources.first().map(String::as_str); - // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 - let candidate_start_index = target_level.candidates.len(); - let ai_redraw = payload.ai_redraw.unwrap_or(true); - let mut candidates = if should_use_uploaded_puzzle_image_directly( - primary_reference_image_src, - ai_redraw, - ) { - vec![ - create_uploaded_puzzle_image_candidate( - &state, - owner_user_id.as_str(), - &session.session_id, - &target_level.level_name, - &prompt, - primary_reference_image_src.expect("checked reference image"), - candidate_start_index, - ) - .await?, - ] - } else { - let (_, profile_id) = build_stable_puzzle_work_ids(&session.session_id); - generate_puzzle_image_candidates( - &state, - owner_user_id.as_str(), - Some(profile_id.as_str()), - &session.session_id, - &target_level.level_name, - &prompt, - primary_reference_image_src, - ai_redraw, - payload.image_model.as_deref(), - 1, - candidate_start_index, - ) - .await - .map_err(map_puzzle_generation_endpoint_error)? - }; - if candidates.is_empty() { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( - json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图候选图生成结果为空", - }), - )); - } - if let Some(refined_naming) = generate_puzzle_first_level_name_from_image( - &state, - target_level.picture_description.as_str(), - &candidates[0].downloaded_image, - ) - .await - .filter(|_| should_auto_name_level) - { - target_level.level_name = refined_naming.level_name.clone(); - if refined_naming.ui_background_prompt.is_some() { - target_level.ui_background_prompt = - refined_naming.ui_background_prompt.clone(); - } - generated_naming = Some(refined_naming); - } - let generated_level_name = target_level.level_name.clone(); - let mut updated_levels = build_puzzle_levels_with_primary_update( - &draft, - &target_level, - primary_reference_image_src, - ); - for candidate in &mut candidates { - candidate.record.prompt = prompt.clone(); - } - let selected_candidate = candidates - .iter() - .find(|candidate| candidate.record.selected) - .or_else(|| candidates.first()) - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图候选图生成结果为空", - })) - })?; - let asset_bundle = generate_puzzle_level_asset_bundle_required( - &state, + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })), + ) + })?; + let worker_payload = PuzzleGenerateImagesWorkerPayload { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + billing_asset_id: billing_asset_id.clone(), + level_id: target_level_id.clone(), + prompt_text: payload.prompt_text.clone(), + reference_image_src: payload.reference_image_src.clone(), + reference_image_srcs: payload.reference_image_srcs.clone(), + reference_image_asset_object_id: payload.reference_image_asset_object_id.clone(), + reference_image_asset_object_ids: payload.reference_image_asset_object_ids.clone(), + image_model: payload.image_model.clone(), + ai_redraw: payload.ai_redraw, + should_auto_name_level: payload.should_auto_name_level, + work_title: payload.work_title.clone(), + work_description: payload.work_description.clone(), + picture_description: payload.picture_description.clone(), + summary: payload.summary.clone(), + theme_tags: payload.theme_tags.clone(), + levels_json, + requested_at_micros: now, + }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图关卡图片生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_generate_images_worker_job( + &state, + &request_context, + worker_payload, + ExternalGenerationWriteLeaseGuard::inline(), + ) + .await + .map_err(|error| { + puzzle_error_response( &request_context, - owner_user_id.as_str(), - &session.session_id, - &target_level, - &selected_candidate.downloaded_image, + PUZZLE_AGENT_API_BASE_PROVIDER, + error.into_app_error(), ) - .await?; - attach_puzzle_level_asset_bundle( - &mut updated_levels, - target_level.level_id.as_str(), - asset_bundle, - ); - attach_selected_puzzle_candidate_to_levels( - &mut updated_levels, - target_level.level_id.as_str(), - &selected_candidate.record, - ); - let levels_json_with_generated_name = - Some(serialize_puzzle_level_records_for_module(&updated_levels)?); - let candidates_json = serde_json::to_string( - &candidates - .iter() - .map(|candidate| to_puzzle_generated_image_candidate(&candidate.record)) - .collect::>(), + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "generate_puzzle_images".to_string(), + status: "completed".to_string(), + phase_label: "拼图图片生成".to_string(), + phase_detail: "关卡图片生成已完成。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡图片生成任务参数序列化失败:{error}"), + })), + ) + })?; + let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let source_entity_id = target_level_id + .as_deref() + .map(|level_id| format!("{session_id}:{level_id}")) + .unwrap_or_else(|| session_id.clone()); + let job = state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + job_id: external_generation_job_id.clone(), + dedupe_key: format!( + "puzzle:generate_puzzle_images:{session_id}:{external_generation_job_id}" + ), + job_kind: PUZZLE_GENERATE_IMAGES_JOB_KIND.to_string(), + owner_user_id: owner_user_id.clone(), + source_module: "puzzle".to_string(), + source_entity_id, + request_label: "拼图关卡图片生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now, + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), ) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": format!("拼图候选图序列化失败:{error}"), - })) - })?; - let save_result = state - .spacetime_client() - .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { - session_id: session.session_id.clone(), - owner_user_id: owner_user_id.clone(), - level_id: Some(target_level.level_id.clone()), - levels_json: levels_json_with_generated_name, - candidates_json, - saved_at_micros: now, - }) - .await; - match save_result { - Ok(session) => Ok(session), - Err(error) - if should_skip_asset_operation_billing_for_connectivity(&error) => - { - // 中文注释:VectorEngine/OSS 已生成真实图片时,SpacetimeDB 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %owner_user_id, - error = %error, - "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" - ); - let fallback_session = - replace_puzzle_session_draft_snapshot(session, draft, now); - let fallback_session = if should_auto_name_level { - apply_generated_puzzle_first_level_name_to_session_snapshot( - fallback_session, - target_level.level_id.as_str(), - generated_level_name.as_str(), - fallback_level_name.as_str(), - now, - ) - } else { - fallback_session - }; - let mut fallback_session = - apply_generated_puzzle_candidates_to_session_snapshot( - apply_generated_puzzle_levels_to_session_snapshot( - fallback_session, - updated_levels, - now, - ), - target_level.level_id.as_str(), - candidates.into_records(), - primary_reference_image_src, - now, - ); - if let Some(generated_naming) = generated_naming.as_ref() { - fallback_session = - apply_generated_puzzle_metadata_to_session_snapshot( - fallback_session, - target_level.level_id.as_str(), - generated_naming, - fallback_level_name.as_str(), - now, - ); - } - Ok(fallback_session) - } - Err(error) => Err(map_puzzle_client_error(error)), - } + })?; + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let (status, progress) = match job.status.as_str() { + "completed" => ("completed", 100), + "running" => ("running", 35), + "failed" => ("failed", 0), + _ => ("queued", 8), + }; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: job.job_id, + operation_type: "generate_puzzle_images".to_string(), + status: status.to_string(), + phase_label: "拼图图片生成".to_string(), + phase_detail: "关卡图片生成已进入后台队列。".to_string(), + progress, + error: job.last_error_message, + }, + session: map_puzzle_agent_session_response(session), }, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "generate_puzzle_images", - "拼图图片生成", - "已生成并替换当前拼图图片。", - session, - ) + )); } "generate_puzzle_ui_background" => { let target_level_id = payload.level_id.clone(); - let raw_prompt = payload - .prompt_text - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or_default() - .to_string(); let levels_json = normalize_puzzle_levels_json_for_module( payload.levels_json.as_deref(), ) .map_err(|message| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })) - }); - let session = execute_billable_asset_operation_with_cost( - state.root_state(), - &owner_user_id, - "puzzle_ui_background_image", - &billing_asset_id, - PUZZLE_IMAGE_GENERATION_POINTS_COST, - async { - let levels_json = levels_json?; - let session = get_puzzle_session_for_image_generation( - &state, - session_id.clone(), - owner_user_id.clone(), - &payload, - levels_json.as_deref(), - now, - ) - .await?; - let mut draft = session.draft.clone().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": "拼图结果页草稿尚未生成", - })) - })?; - if let Some(levels_json) = levels_json.as_ref() { - draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; - } - let target_level = - select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; - let resolved_prompt = normalize_puzzle_ui_background_prompt( - raw_prompt.as_str(), - &draft, - &target_level, - ); - let generated = generate_puzzle_ui_background_image( - &state, + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })), + ) + })?; + let worker_payload = PuzzleGenerateUiBackgroundWorkerPayload { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + billing_asset_id: billing_asset_id.clone(), + level_id: target_level_id.clone(), + prompt_text: payload.prompt_text.clone(), + levels_json, + requested_at_micros: now, + }; + if state + .root_state() + .config + .external_generation_mode + .is_inline() + { + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + external_generation_mode = state.root_state().config.external_generation_mode.as_str(), + "拼图 UI 背景图生成使用 inline 模式同步执行" + ); + let session = execute_puzzle_generate_ui_background_worker_job( + &state, + &request_context, + worker_payload, + ExternalGenerationWriteLeaseGuard::inline(), + ) + .await + .map_err(|error| { + puzzle_error_response( &request_context, - owner_user_id.as_str(), - &session.session_id, - &target_level.level_name, - resolved_prompt.as_str(), + PUZZLE_AGENT_API_BASE_PROVIDER, + error.into_app_error(), ) - .await - .map_err(map_puzzle_generation_endpoint_error)?; - let save_result = state - .spacetime_client() - .save_puzzle_ui_background(PuzzleUiBackgroundSaveRecordInput { - session_id: session.session_id.clone(), - owner_user_id: owner_user_id.clone(), - level_id: Some(target_level.level_id.clone()), - levels_json, - prompt: resolved_prompt.clone(), - image_src: generated.image_src.clone(), - image_object_key: Some(generated.object_key.clone()), - saved_at_micros: now, - }) - .await; - match save_result { - Ok(session) => Ok(session), - Err(error) - if should_skip_asset_operation_billing_for_connectivity(&error) => - { - tracing::warn!( - provider = PUZZLE_AGENT_API_BASE_PROVIDER, - session_id = %session.session_id, - owner_user_id = %owner_user_id, - error = %error, - "拼图 UI 背景图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" - ); - let fallback_session = - replace_puzzle_session_draft_snapshot(session, draft, now); - Ok(apply_generated_puzzle_ui_background_to_session_snapshot( - fallback_session, - target_level.level_id.as_str(), - resolved_prompt, - generated.image_src, - Some(generated.object_key), - now, - )) - } - Err(error) => Err(map_puzzle_client_error(error)), - } + })?; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: build_prefixed_uuid_id("extgen-inline-"), + operation_type: "generate_puzzle_ui_background".to_string(), + status: "completed".to_string(), + phase_label: "UI 背景图生成".to_string(), + phase_detail: "拼图 UI 背景图生成已完成。".to_string(), + progress: 100, + error: None, + }, + session: map_puzzle_agent_session_response(session), + }, + )); + } + let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图 UI 背景图生成任务参数序列化失败:{error}"), + })), + ) + })?; + let external_generation_job_id = build_prefixed_uuid_id("extgen-"); + let source_entity_id = target_level_id + .as_deref() + .map(|level_id| format!("{session_id}:{level_id}")) + .unwrap_or_else(|| session_id.clone()); + let job = state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + job_id: external_generation_job_id.clone(), + dedupe_key: format!( + "puzzle:generate_puzzle_ui_background:{session_id}:{external_generation_job_id}" + ), + job_kind: PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND.to_string(), + owner_user_id: owner_user_id.clone(), + source_module: "puzzle".to_string(), + source_entity_id, + request_label: "拼图 UI 背景图生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now, + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + let (status, progress) = match job.status.as_str() { + "completed" => ("completed", 100), + "running" => ("running", session.progress_percent.max(55)), + "failed" => ("failed", session.progress_percent), + _ => ("queued", session.progress_percent.max(12)), + }; + return Ok(json_success_body( + Some(&request_context), + PuzzleAgentActionResponse { + operation: PuzzleAgentOperationResponse { + operation_id: job.job_id, + operation_type: "generate_puzzle_ui_background".to_string(), + status: status.to_string(), + phase_label: "UI 背景图生成".to_string(), + phase_detail: "拼图 UI 背景图生成已进入后台队列。".to_string(), + progress, + error: job.last_error_message, + }, + session: map_puzzle_agent_session_response(session), }, - ) - .await - .map_err(|error| { - puzzle_error_response(&request_context, PUZZLE_AGENT_API_BASE_PROVIDER, error) - }); - ( - "generate_puzzle_ui_background", - "UI 背景图生成", - "已生成拼图 UI 背景图。", - session, - ) + )); } "generate_puzzle_tags" => { let work_title = payload @@ -1535,27 +1405,6 @@ pub async fn execute_puzzle_agent_action( }; let session = session?; - if operation_type == "compile_puzzle_draft" - && session - .draft - .as_ref() - .is_some_and(|draft| draft.generation_status == "ready") - { - send_generation_result_subscribe_message_after_completion( - state.root_state(), - GenerationResultSubscribeMessage { - owner_user_id: owner_user_id.clone(), - task_name: Some("拼图".to_string()), - work_name: session.draft.as_ref().map(|draft| draft.work_title.clone()), - status: GenerationResultSubscribeMessageStatus::Succeeded, - consumed_points: operation_consumed_points, - completed_at_micros: current_utc_micros(), - page: Some("/pages/web-view/index".to_string()), - }, - ) - .await; - } - Ok(json_success_body( Some(&request_context), PuzzleAgentActionResponse { diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index 31ccf74c..dee3d419 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -535,6 +535,108 @@ fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() { assert_eq!(session.stage, "ready_to_publish"); } +#[test] +fn puzzle_generate_images_worker_payload_keeps_action_snapshot() { + let raw_levels_json = serde_json::to_string(&vec![json!({ + "levelId": "puzzle-level-2", + "levelName": "", + "pictureDescription": "新关卡里有一座发光钟楼。", + "candidates": [], + "selectedCandidateId": null, + "coverImageSrc": null, + "coverAssetId": null, + "generationStatus": "generating", + })]) + .expect("levels json"); + let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str())) + .expect("levels should normalize") + .expect("levels json should exist"); + let payload = PuzzleGenerateImagesWorkerPayload { + session_id: "puzzle-session-1".to_string(), + owner_user_id: "user-1".to_string(), + billing_asset_id: "puzzle-session-1:123".to_string(), + level_id: Some("puzzle-level-2".to_string()), + prompt_text: Some("发光钟楼".to_string()), + reference_image_src: None, + reference_image_srcs: vec!["data:image/png;base64,abc".to_string()], + reference_image_asset_object_id: Some("asset-object-1".to_string()), + reference_image_asset_object_ids: vec!["asset-object-2".to_string()], + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + ai_redraw: Some(true), + should_auto_name_level: Some(true), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + summary: Some("一套雨夜猫街主题拼图。".to_string()), + theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]), + levels_json: Some(levels_json.clone()), + requested_at_micros: 123, + }; + + let encoded = serde_json::to_string(&payload).expect("payload should serialize"); + let decoded: PuzzleGenerateImagesWorkerPayload = + serde_json::from_str(encoded.as_str()).expect("payload should deserialize"); + + assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-2")); + assert_eq!(decoded.reference_image_srcs.len(), 1); + assert_eq!( + decoded.reference_image_asset_object_ids, + vec!["asset-object-2".to_string()] + ); + assert_eq!(decoded.should_auto_name_level, Some(true)); + let records = parse_puzzle_level_records_from_module_json( + decoded.levels_json.as_deref().expect("levels json"), + ) + .expect("levels should parse as module json"); + assert_eq!(records[0].level_id, "puzzle-level-2"); + assert_eq!(records[0].generation_status, "generating"); +} + +#[test] +fn puzzle_generate_ui_background_worker_payload_keeps_action_snapshot() { + let raw_levels_json = serde_json::to_string(&vec![json!({ + "levelId": "puzzle-level-3", + "levelName": "钟楼回廊", + "pictureDescription": "新关卡里有一座发光钟楼。", + "uiBackgroundPrompt": "发光钟楼延展成竖屏回廊,远处有暖色窗光。", + "candidates": [], + "selectedCandidateId": null, + "coverImageSrc": null, + "coverAssetId": null, + "generationStatus": "generating", + })]) + .expect("levels json"); + let levels_json = normalize_puzzle_levels_json_for_module(Some(raw_levels_json.as_str())) + .expect("levels should normalize") + .expect("levels json should exist"); + let payload = PuzzleGenerateUiBackgroundWorkerPayload { + session_id: "puzzle-session-1".to_string(), + owner_user_id: "user-1".to_string(), + billing_asset_id: "puzzle-session-1:456".to_string(), + level_id: Some("puzzle-level-3".to_string()), + prompt_text: Some("发光钟楼延展成竖屏回廊".to_string()), + levels_json: Some(levels_json.clone()), + requested_at_micros: 456, + }; + + let encoded = serde_json::to_string(&payload).expect("payload should serialize"); + let decoded: PuzzleGenerateUiBackgroundWorkerPayload = + serde_json::from_str(encoded.as_str()).expect("payload should deserialize"); + + assert_eq!(decoded.level_id.as_deref(), Some("puzzle-level-3")); + assert_eq!( + decoded.prompt_text.as_deref(), + Some("发光钟楼延展成竖屏回廊") + ); + assert_eq!(decoded.requested_at_micros, 456); + let records = parse_puzzle_level_records_from_module_json( + decoded.levels_json.as_deref().expect("levels json"), + ) + .expect("levels should parse as module json"); + assert_eq!(records[0].level_id, "puzzle-level-3"); + assert_eq!(records[0].generation_status, "generating"); +} + #[test] fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() { assert_eq!( diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 1221df47..8c88dd71 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -11,7 +11,11 @@ use module_assets::{ generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::puzzle_clear::{ PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, @@ -22,7 +26,9 @@ use shared_contracts::puzzle_clear::{ PuzzleClearWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, @@ -51,6 +57,7 @@ const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation"; const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime"; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; +pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft"; const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs"; const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256; const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4; @@ -76,6 +83,15 @@ const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0; const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PuzzleClearCompileDraftWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub payload: PuzzleClearActionRequest, +} + pub async fn create_puzzle_clear_session( State(state): State, Extension(request_context): Extension, @@ -160,6 +176,39 @@ pub async fn execute_puzzle_clear_action( .unwrap_or("拼消消玩家") .to_string(); let mut payload = payload; + let should_queue_generation = matches!( + payload.action_type, + PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_puzzle_clear_generation_queued( + session_id.clone(), + owner_user_id.clone(), + author_display_name.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + puzzle_clear_error_response( + &request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + })?; + let queue_job = enqueue_puzzle_clear_compile_draft_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + author_display_name.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( &state, &request_context, @@ -210,6 +259,129 @@ pub async fn execute_puzzle_clear_action( Ok(json_success_body(Some(&request_context), response)) } +async fn enqueue_puzzle_clear_compile_draft_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, + payload: PuzzleClearActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + author_display_name: author_display_name.to_string(), + payload, + }) + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("拼消消 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"), + job_id, + job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "puzzle-clear".to_string(), + source_entity_id: session_id.to_string(), + request_label: "拼消消草稿生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + }) +} + +fn map_puzzle_clear_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: PuzzleClearCompileDraftWorkerPayload, +) -> Result { + if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + &mut worker_payload.payload, + ) + .await + { + let (error_message, response) = extract_puzzle_clear_response_error_message(response).await; + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + session_id = worker_payload.session_id, + error = %error_message, + "拼消消 worker 素材生成失败,准备回写 failed 状态" + ); + if let Err(writeback_error) = state + .spacetime_client() + .mark_puzzle_clear_generation_failed( + worker_payload.session_id.clone(), + worker_payload.owner_user_id.clone(), + worker_payload.author_display_name.clone(), + worker_payload.payload.clone(), + ) + .await + { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + session_id = worker_payload.session_id, + error = %writeback_error, + "拼消消 worker 失败状态回写失败" + ); + } + return Err(response); + } + let response = state + .spacetime_client() + .execute_puzzle_clear_action( + worker_payload.session_id, + worker_payload.owner_user_id, + worker_payload.author_display_name, + worker_payload.payload, + ) + .await + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + })?; + Ok(response.session) +} + pub async fn list_puzzle_clear_works( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a8c46668..9f583dd6 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -14,7 +14,11 @@ use module_assets::{ build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::wooden_fish::{ WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, @@ -24,7 +28,9 @@ use shared_contracts::wooden_fish::{ WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -54,6 +60,8 @@ const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation"; const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime"; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; +pub(crate) const WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND: &str = + "wooden_fish_generate_image_assets"; const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"; @@ -73,6 +81,15 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( )); const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家"; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WoodenFishGenerateImageAssetsWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub payload: WoodenFishActionRequest, +} + pub async fn create_wooden_fish_session( State(state): State, Extension(request_context): Extension, @@ -155,6 +172,40 @@ pub async fn execute_wooden_fish_action( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft ); + let should_queue_generation = matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + | shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_wooden_fish_generation_queued( + session_id.clone(), + owner_user_id.clone(), + author_display_name.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + let queue_job = enqueue_wooden_fish_generate_image_assets_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + author_display_name.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_wooden_fish_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } let generation_points_cost = if is_compile_draft { resolve_wooden_fish_generation_points_cost(&state).await } else { @@ -226,6 +277,70 @@ pub async fn execute_wooden_fish_action( Ok(json_success_body(Some(&request_context), response)) } +async fn enqueue_wooden_fish_generate_image_assets_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, + payload: WoodenFishActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + author_display_name: author_display_name.to_string(), + payload, + }) + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("敲木鱼 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("wooden-fish:generate-image-assets:{session_id}:{job_id}"), + job_id, + job_kind: WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "wooden-fish".to_string(), + source_entity_id: session_id.to_string(), + request_label: "敲木鱼图片素材生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + }) +} + +fn map_wooden_fish_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + pub async fn publish_wooden_fish_work( State(state): State, Path(profile_id): Path, @@ -635,6 +750,40 @@ async fn execute_wooden_fish_action_with_generated_assets( }) } +pub(crate) async fn execute_wooden_fish_generate_image_assets_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: WoodenFishGenerateImageAssetsWorkerPayload, +) -> Result { + let result = execute_wooden_fish_action_with_generated_assets( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + worker_payload.author_display_name.as_str(), + &mut worker_payload.payload, + ) + .await; + if result.as_ref().err().is_some_and(|response| { + response.status().is_server_error() + && matches!( + worker_payload.payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ) + }) { + mark_wooden_fish_generation_failed( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + worker_payload.author_display_name.as_str(), + ) + .await; + } + let response = result?; + Ok(response.session) +} + async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, diff --git a/server-rs/crates/module-puzzle/src/commands.rs b/server-rs/crates/module-puzzle/src/commands.rs index 994ecd9e..e38c60e4 100644 --- a/server-rs/crates/module-puzzle/src/commands.rs +++ b/server-rs/crates/module-puzzle/src/commands.rs @@ -66,6 +66,9 @@ pub struct PuzzleDraftCompileInput { pub session_id: String, pub owner_user_id: String, pub compiled_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -94,6 +97,23 @@ pub struct PuzzleDraftCompileFailureInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleLevelGenerationFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, + pub error_message: String, + pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -105,6 +125,9 @@ pub struct PuzzleGeneratedImagesSaveInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -118,6 +141,9 @@ pub struct PuzzleUiBackgroundSaveInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] diff --git a/server-rs/crates/server-manager-panel/Cargo.toml b/server-rs/crates/server-manager-panel/Cargo.toml new file mode 100644 index 00000000..cf41e79f --- /dev/null +++ b/server-rs/crates/server-manager-panel/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "server-manager-panel" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +eframe = { version = "0.33", default-features = false, features = [ + "default_fonts", + "glow", + "wayland", + "x11", +] } diff --git a/server-rs/crates/server-manager-panel/src/app.rs b/server-rs/crates/server-manager-panel/src/app.rs new file mode 100644 index 00000000..29463956 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/app.rs @@ -0,0 +1,577 @@ +use eframe::egui; + +use crate::health::{ + DiskSnapshot, HealthLevel, MemorySnapshot, ProbeSnapshot, ServerHealthReport, ServiceSnapshot, +}; +use crate::remote::{ + RemoteEvent, RemoteReceiver, RemoteSender, ServiceAction, channel, spawn_health_check, + spawn_service_action, +}; +use crate::ssh_config::{SshAlias, discover_ssh_aliases}; + +const DEFAULT_MANAGED_SERVICES: &[&str] = &[ + "genarrative-api.service", + "spacetimedb.service", + "nginx.service", + "genarrative-health-patrol.timer", + "genarrative-database-backup.timer", +]; + +#[derive(Debug)] +pub struct ServerManagerApp { + servers: Vec, + selected_alias: Option, + sidebar_collapsed: bool, + tx: RemoteSender, + rx: RemoteReceiver, + pending_confirmation: Option, + custom_service_name: String, +} + +impl Default for ServerManagerApp { + fn default() -> Self { + let (tx, rx) = channel(); + let aliases = discover_ssh_aliases(); + let selected_alias = aliases.first().map(|alias| alias.name.clone()); + Self { + servers: aliases.into_iter().map(ServerState::new).collect(), + selected_alias, + sidebar_collapsed: false, + tx, + rx, + pending_confirmation: None, + custom_service_name: String::new(), + } + } +} + +impl eframe::App for ServerManagerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.drain_remote_events(ctx); + self.render_confirm_dialog(ctx); + + egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + if ui + .button(if self.sidebar_collapsed { + "展开侧栏" + } else { + "收起侧栏" + }) + .clicked() + { + self.sidebar_collapsed = !self.sidebar_collapsed; + } + if ui.button("重新读取 SSH alias").clicked() { + self.reload_aliases(); + } + if let Some(alias) = self.selected_alias.clone() { + if ui.button("刷新当前服务器").clicked() { + self.refresh_server(&alias); + } + } + ui.separator(); + ui.label("本地 SSH alias 管理"); + }); + }); + + if !self.sidebar_collapsed { + egui::SidePanel::left("server_sidebar") + .resizable(true) + .default_width(260.0) + .width_range(180.0..=360.0) + .show(ctx, |ui| self.render_sidebar(ui)); + } + + egui::CentralPanel::default().show(ctx, |ui| { + if self.servers.is_empty() { + self.render_empty_state(ui); + return; + } + + let Some(alias) = self.selected_alias.clone() else { + self.render_empty_state(ui); + return; + }; + + if let Some(index) = self.server_index(&alias) { + self.render_server_detail(ui, index); + } else { + ui.label("请选择服务器"); + } + }); + } +} + +impl ServerManagerApp { + fn drain_remote_events(&mut self, ctx: &egui::Context) { + while let Ok(event) = self.rx.try_recv() { + match event { + RemoteEvent::Health { alias, result } => { + if let Some(server) = self.server_mut(&alias) { + server.loading = false; + match result { + Ok(report) => { + server.error = None; + server.report = Some(report); + } + Err(error) => { + server.error = Some(error); + } + } + } + } + RemoteEvent::ServiceAction { + alias, + service, + action, + result, + } => { + if let Some(server) = self.server_mut(&alias) { + server.action_in_progress = None; + server.action_log = Some(format!( + "{} {}: {}\n{}{}", + action.label(), + service, + result.summary, + result.stdout, + result.stderr + )); + server.loading = true; + spawn_health_check(alias, self.tx.clone()); + } + } + } + ctx.request_repaint(); + } + } + + fn render_sidebar(&mut self, ui: &mut egui::Ui) { + ui.heading("服务器"); + ui.add_space(8.0); + let mut refresh_alias: Option = None; + + for server in &mut self.servers { + let selected = self.selected_alias.as_deref() == Some(server.alias.name.as_str()); + let response = ui.selectable_label(selected, server_label(server)); + if response.clicked() { + self.selected_alias = Some(server.alias.name.clone()); + } + response.on_hover_text(server.alias.source.display().to_string()); + ui.horizontal(|ui| { + let status = server.status(); + ui.colored_label(level_color(status), status.label()); + if server.loading { + ui.spinner(); + } + if ui.small_button("刷新").clicked() { + refresh_alias = Some(server.alias.name.clone()); + } + }); + ui.add_space(6.0); + } + + if let Some(alias) = refresh_alias { + self.refresh_server(&alias); + } + } + + fn render_empty_state(&mut self, ui: &mut egui::Ui) { + ui.vertical_centered(|ui| { + ui.heading("未发现 SSH alias"); + ui.label("请在 ~/.ssh/config 中配置 Host alias 后重新读取。"); + if ui.button("重新读取").clicked() { + self.reload_aliases(); + } + }); + } + + fn render_server_detail(&mut self, ui: &mut egui::Ui, index: usize) { + let alias = self.servers[index].alias.name.clone(); + let status = self.servers[index].status(); + let loading = self.servers[index].loading; + let report = self.servers[index].report.clone(); + let error = self.servers[index].error.clone(); + let action_log = self.servers[index].action_log.clone(); + + ui.horizontal(|ui| { + ui.heading(&alias); + ui.colored_label(level_color(status), status.label()); + if loading { + ui.spinner(); + } + }); + ui.add_space(8.0); + + if let Some(error) = error { + ui.colored_label(warning_color(), format!("SSH 巡检失败:{error}")); + ui.add_space(8.0); + } + + if let Some(report) = report { + self.render_report(ui, &alias, &report); + } else { + ui.label("尚未执行巡检。"); + } + + ui.add_space(12.0); + self.render_service_controls(ui, &alias, index); + + if let Some(log) = action_log { + ui.add_space(12.0); + egui::CollapsingHeader::new("最近一次服务操作输出") + .default_open(true) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut log.clone()) + .font(egui::TextStyle::Monospace) + .desired_rows(8) + .interactive(false), + ); + }); + } + } + + fn render_report(&self, ui: &mut egui::Ui, alias: &str, report: &ServerHealthReport) { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + info_chip(ui, "主机", value_or_dash(&report.host.hostname)); + info_chip(ui, "内核", value_or_dash(&report.host.kernel)); + info_chip(ui, "运行时间", value_or_dash(&report.host.uptime)); + info_chip(ui, "检查时间", value_or_dash(&report.checked_at)); + }); + + ui.add_space(10.0); + egui::CollapsingHeader::new("硬件状态") + .default_open(true) + .show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + info_chip(ui, "CPU", value_or_dash(&report.hardware.cpu_model)); + info_chip(ui, "核心", value_or_dash(&report.hardware.cpu_cores)); + info_chip(ui, "负载", value_or_dash(&report.hardware.load_average)); + }); + ui.add_space(6.0); + memory_row(ui, "内存", &report.hardware.memory); + memory_row(ui, "Swap", &report.hardware.swap); + ui.add_space(6.0); + for disk in &report.hardware.disks { + disk_row(ui, disk); + } + ui.add_space(6.0); + for sensor in &report.hardware.sensors { + ui.label(sensor); + } + }); + + egui::CollapsingHeader::new("服务状态") + .default_open(true) + .show(ui, |ui| { + egui::Grid::new(format!("{alias}_services")) + .striped(true) + .show(ui, |ui| { + ui.strong("服务"); + ui.strong("状态"); + ui.strong("子状态"); + ui.strong("Unit"); + ui.end_row(); + for service in &report.services { + service_row(ui, service); + } + }); + }); + + egui::CollapsingHeader::new("HTTP 探测") + .default_open(true) + .show(ui, |ui| { + egui::Grid::new(format!("{alias}_probes")) + .striped(true) + .show(ui, |ui| { + ui.strong("探测"); + ui.strong("状态码"); + ui.strong("耗时"); + ui.strong("目标"); + ui.end_row(); + for probe in &report.probes { + probe_row(ui, probe); + } + }); + }); + + if let Some(patrol) = &report.health_patrol { + egui::CollapsingHeader::new("生产健康巡检") + .default_open(true) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.colored_label(level_color(patrol.level), &patrol.status); + ui.label(value_or_dash(&patrol.checked_at)); + ui.label(value_or_dash(&patrol.summary)); + }); + }); + } + + egui::CollapsingHeader::new("原始巡检输出").show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut report.raw_output.clone()) + .font(egui::TextStyle::Monospace) + .desired_rows(12) + .interactive(false), + ); + }); + }); + } + + fn render_service_controls(&mut self, ui: &mut egui::Ui, alias: &str, index: usize) { + ui.heading("服务控制"); + ui.add_space(4.0); + + let action_in_progress = self.servers[index].action_in_progress.clone(); + for service in DEFAULT_MANAGED_SERVICES { + ui.horizontal(|ui| { + ui.label(*service); + for action in [ + ServiceAction::Start, + ServiceAction::Stop, + ServiceAction::Restart, + ] { + let disabled = action_in_progress.is_some(); + if ui + .add_enabled(!disabled, egui::Button::new(action.label())) + .clicked() + { + self.pending_confirmation = Some(ServiceConfirmation { + alias: alias.to_owned(), + service: (*service).to_owned(), + action, + }); + } + } + }); + } + + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("其他 unit"); + ui.text_edit_singleline(&mut self.custom_service_name); + if ui.button("启动").clicked() { + self.confirm_custom_service(alias, ServiceAction::Start); + } + if ui.button("关闭").clicked() { + self.confirm_custom_service(alias, ServiceAction::Stop); + } + if ui.button("重启").clicked() { + self.confirm_custom_service(alias, ServiceAction::Restart); + } + }); + + if let Some(action) = action_in_progress { + ui.label(format!("正在执行:{action}")); + } + } + + fn render_confirm_dialog(&mut self, ctx: &egui::Context) { + let Some(confirmation) = self.pending_confirmation.clone() else { + return; + }; + + egui::Window::new("确认服务操作") + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label(format!( + "确认在 {} 上{} {}?", + confirmation.alias, + confirmation.action.label(), + confirmation.service + )); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("确认").clicked() { + self.execute_service_action(&confirmation); + self.pending_confirmation = None; + } + if ui.button("取消").clicked() { + self.pending_confirmation = None; + } + }); + }); + } + + fn reload_aliases(&mut self) { + let aliases = discover_ssh_aliases(); + self.servers = aliases.into_iter().map(ServerState::new).collect(); + self.selected_alias = self.servers.first().map(|server| server.alias.name.clone()); + } + + fn refresh_server(&mut self, alias: &str) { + if let Some(server) = self.server_mut(alias) { + server.loading = true; + server.error = None; + } + spawn_health_check(alias.to_owned(), self.tx.clone()); + } + + fn confirm_custom_service(&mut self, alias: &str, action: ServiceAction) { + let service = self.custom_service_name.trim(); + if service.is_empty() { + return; + } + self.pending_confirmation = Some(ServiceConfirmation { + alias: alias.to_owned(), + service: service.to_owned(), + action, + }); + } + + fn execute_service_action(&mut self, confirmation: &ServiceConfirmation) { + if let Some(server) = self.server_mut(&confirmation.alias) { + server.action_in_progress = Some(format!( + "{} {}", + confirmation.action.label(), + confirmation.service + )); + server.action_log = None; + } + spawn_service_action( + confirmation.alias.clone(), + confirmation.service.clone(), + confirmation.action, + self.tx.clone(), + ); + } + + fn server_index(&self, alias: &str) -> Option { + self.servers + .iter() + .position(|server| server.alias.name == alias) + } + + fn server_mut(&mut self, alias: &str) -> Option<&mut ServerState> { + self.servers + .iter_mut() + .find(|server| server.alias.name == alias) + } +} + +#[derive(Debug, Clone)] +struct ServiceConfirmation { + alias: String, + service: String, + action: ServiceAction, +} + +#[derive(Debug)] +struct ServerState { + alias: SshAlias, + report: Option, + loading: bool, + error: Option, + action_in_progress: Option, + action_log: Option, +} + +impl ServerState { + fn new(alias: SshAlias) -> Self { + Self { + alias, + report: None, + loading: false, + error: None, + action_in_progress: None, + action_log: None, + } + } + + fn status(&self) -> HealthLevel { + if self.error.is_some() { + HealthLevel::Critical + } else { + self.report + .as_ref() + .map(|report| report.status) + .unwrap_or(HealthLevel::Unknown) + } + } +} + +fn server_label(server: &ServerState) -> String { + let prefix = match server.status() { + HealthLevel::Ok => "[OK]", + HealthLevel::Warning => "[!]", + HealthLevel::Critical => "[X]", + HealthLevel::Unknown => "[?]", + }; + format!("{prefix} {}", server.alias.name) +} + +fn service_row(ui: &mut egui::Ui, service: &ServiceSnapshot) { + ui.label(&service.name); + ui.colored_label(level_color(service.level), &service.active); + ui.label(&service.sub); + ui.label(&service.unit_file); + ui.end_row(); +} + +fn probe_row(ui: &mut egui::Ui, probe: &ProbeSnapshot) { + ui.label(&probe.name); + ui.colored_label(level_color(probe.level), &probe.http_code); + ui.label( + probe + .elapsed_ms + .map(|elapsed| format!("{elapsed}ms")) + .unwrap_or_else(|| "-".to_owned()), + ); + ui.label(&probe.target); + ui.end_row(); +} + +fn memory_row(ui: &mut egui::Ui, label: &str, memory: &MemorySnapshot) { + let percent = memory.used_percent.unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(label); + ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%"))); + ui.label(format!( + "已用 {} / 总计 {},可用 {}", + value_or_dash(&memory.used), + value_or_dash(&memory.total), + value_or_dash(&memory.available) + )); + }); +} + +fn disk_row(ui: &mut egui::Ui, disk: &DiskSnapshot) { + let percent = disk.used_percent.unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(&disk.mount); + ui.add(egui::ProgressBar::new(f32::from(percent) / 100.0).text(format!("{percent}%"))); + ui.label(format!( + "{} 已用 {} / {},可用 {}", + disk.filesystem, disk.used, disk.size, disk.available + )); + }); +} + +fn info_chip(ui: &mut egui::Ui, label: &str, value: &str) { + ui.group(|ui| { + ui.vertical(|ui| { + ui.small(label); + ui.label(value); + }); + }); +} + +fn value_or_dash(value: &str) -> &str { + if value.trim().is_empty() { "-" } else { value } +} + +fn level_color(level: HealthLevel) -> egui::Color32 { + match level { + HealthLevel::Ok => egui::Color32::from_rgb(38, 166, 91), + HealthLevel::Warning => egui::Color32::from_rgb(214, 137, 16), + HealthLevel::Critical => egui::Color32::from_rgb(205, 66, 70), + HealthLevel::Unknown => egui::Color32::from_rgb(120, 126, 136), + } +} + +fn warning_color() -> egui::Color32 { + egui::Color32::from_rgb(205, 66, 70) +} diff --git a/server-rs/crates/server-manager-panel/src/fonts.rs b/server-rs/crates/server-manager-panel/src/fonts.rs new file mode 100644 index 00000000..298e0e04 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/fonts.rs @@ -0,0 +1,128 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use eframe::egui::{FontData, FontDefinitions, FontFamily}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CjkFontCandidate { + pub path: PathBuf, + pub index: u32, +} + +pub fn install_cjk_font(ctx: &eframe::egui::Context) -> Option { + let candidate = find_cjk_font_candidate()?; + let bytes = std::fs::read(&candidate.path).ok()?; + let mut font_data = FontData::from_owned(bytes); + font_data.index = candidate.index; + + let mut definitions = FontDefinitions::default(); + definitions + .font_data + .insert("genarrative-cjk".to_owned(), Arc::new(font_data)); + + // 中文注释:作为 fallback 注入,保留 egui 默认拉丁/图标字体,同时补齐中文 glyph。 + for family in [FontFamily::Proportional, FontFamily::Monospace] { + definitions + .families + .entry(family) + .or_default() + .push("genarrative-cjk".to_owned()); + } + + ctx.set_fonts(definitions); + Some(candidate) +} + +pub fn find_cjk_font_candidate() -> Option { + if let Ok(path) = std::env::var("GENARRATIVE_SERVER_PANEL_CJK_FONT") { + if let Some(candidate) = parse_font_spec(&path) { + return Some(candidate); + } + } + + const KNOWN_PATHS: &[(&str, u32)] = &[ + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), + ("/usr/share/fonts/opentype/noto/NotoSansCJK-Medium.ttc", 2), + ("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0), + ("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", 0), + ( + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + 0, + ), + ( + "/home/dsk/.local/share/fonts/genarrative-cjk/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + 0, + ), + ]; + + for (path, index) in KNOWN_PATHS { + if Path::new(path).is_file() { + return Some(CjkFontCandidate { + path: PathBuf::from(path), + index: *index, + }); + } + } + + for family in [ + "Noto Sans CJK SC", + "WenQuanYi Zen Hei", + "Droid Sans Fallback", + ] { + if let Some(candidate) = find_with_fc_match(family) { + return Some(candidate); + } + } + + None +} + +fn parse_font_spec(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let (path, index) = trimmed + .rsplit_once('|') + .and_then(|(path, index)| Some((path, index.parse().ok()?))) + .unwrap_or((trimmed, 0)); + let path = PathBuf::from(path); + path.is_file().then_some(CjkFontCandidate { path, index }) +} + +fn find_with_fc_match(family: &str) -> Option { + let output = Command::new("fc-match") + .arg("-f") + .arg("%{file}|%{index}\n") + .arg(family) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout.lines().find_map(parse_font_spec) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_font_path_with_index() { + let candidate = parse_font_spec("/tmp/missing-font.ttc|2"); + assert_eq!(candidate, None); + } + + #[test] + fn finds_existing_system_cjk_font() { + let candidate = find_cjk_font_candidate(); + assert!( + candidate + .as_ref() + .is_some_and(|candidate| candidate.path.is_file()), + "expected at least one CJK font on this development host" + ); + } +} diff --git a/server-rs/crates/server-manager-panel/src/health.rs b/server-rs/crates/server-manager-panel/src/health.rs new file mode 100644 index 00000000..beacc619 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/health.rs @@ -0,0 +1,474 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum HealthLevel { + Unknown, + Ok, + Warning, + Critical, +} + +impl HealthLevel { + pub fn label(self) -> &'static str { + match self { + HealthLevel::Unknown => "未知", + HealthLevel::Ok => "正常", + HealthLevel::Warning => "警告", + HealthLevel::Critical => "异常", + } + } + + pub fn rank(self) -> u8 { + match self { + HealthLevel::Unknown => 1, + HealthLevel::Ok => 0, + HealthLevel::Warning => 2, + HealthLevel::Critical => 3, + } + } +} + +#[derive(Debug, Clone)] +pub struct ServerHealthReport { + pub status: HealthLevel, + pub checked_at: String, + pub host: HostSnapshot, + pub hardware: HardwareSnapshot, + pub services: Vec, + pub probes: Vec, + pub health_patrol: Option, + pub raw_output: String, +} + +#[derive(Debug, Clone, Default)] +pub struct HostSnapshot { + pub hostname: String, + pub kernel: String, + pub uptime: String, +} + +#[derive(Debug, Clone, Default)] +pub struct HardwareSnapshot { + pub cpu_model: String, + pub cpu_cores: String, + pub load_average: String, + pub memory: MemorySnapshot, + pub swap: MemorySnapshot, + pub disks: Vec, + pub sensors: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MemorySnapshot { + pub total: String, + pub used: String, + pub free: String, + pub available: String, + pub used_percent: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct DiskSnapshot { + pub mount: String, + pub filesystem: String, + pub size: String, + pub used: String, + pub available: String, + pub used_percent: Option, +} + +#[derive(Debug, Clone)] +pub struct ServiceSnapshot { + pub name: String, + pub active: String, + pub sub: String, + pub unit_file: String, + pub level: HealthLevel, +} + +#[derive(Debug, Clone)] +pub struct ProbeSnapshot { + pub name: String, + pub target: String, + pub http_code: String, + pub elapsed_ms: Option, + pub level: HealthLevel, +} + +#[derive(Debug, Clone)] +pub struct HealthPatrolSnapshot { + pub status: String, + pub checked_at: String, + pub summary: String, + pub level: HealthLevel, +} + +pub fn parse_health_report(raw_output: &str) -> ServerHealthReport { + let mut sections: BTreeMap> = BTreeMap::new(); + let mut current = String::new(); + + for line in raw_output.lines() { + if let Some(name) = parse_section_marker(line) { + current = name.to_owned(); + sections.entry(current.clone()).or_default(); + } else if !current.is_empty() { + sections + .entry(current.clone()) + .or_default() + .push(line.to_owned()); + } + } + + let mut report = ServerHealthReport { + status: HealthLevel::Unknown, + checked_at: section_value(§ions, "checked_at").unwrap_or_default(), + host: parse_host(§ions), + hardware: parse_hardware(§ions), + services: parse_services(§ions), + probes: parse_probes(§ions), + health_patrol: parse_health_patrol(§ions), + raw_output: raw_output.to_owned(), + }; + report.status = summarize_report(&report); + report +} + +pub fn summarize_report(report: &ServerHealthReport) -> HealthLevel { + let mut status = HealthLevel::Ok; + for level in report + .services + .iter() + .map(|service| service.level) + .chain(report.probes.iter().map(|probe| probe.level)) + .chain(report.health_patrol.iter().map(|patrol| patrol.level)) + { + if level.rank() > status.rank() { + status = level; + } + } + + if let Some(used_percent) = report.hardware.memory.used_percent { + let memory_level = if used_percent >= 95 { + HealthLevel::Critical + } else if used_percent >= 85 { + HealthLevel::Warning + } else { + HealthLevel::Ok + }; + if memory_level.rank() > status.rank() { + status = memory_level; + } + } + + for disk in &report.hardware.disks { + let disk_level = match disk.used_percent { + Some(percent) if percent >= 95 => HealthLevel::Critical, + Some(percent) if percent >= 85 => HealthLevel::Warning, + _ => HealthLevel::Ok, + }; + if disk_level.rank() > status.rank() { + status = disk_level; + } + } + + status +} + +fn parse_section_marker(line: &str) -> Option<&str> { + line.strip_prefix("==GENARRATIVE_PANEL:") + .and_then(|rest| rest.strip_suffix("==")) +} + +fn section_value(sections: &BTreeMap>, name: &str) -> Option { + sections.get(name).and_then(|lines| { + lines + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .map(str::to_owned) + }) +} + +fn parse_host(sections: &BTreeMap>) -> HostSnapshot { + HostSnapshot { + hostname: section_value(sections, "hostname").unwrap_or_default(), + kernel: section_value(sections, "kernel").unwrap_or_default(), + uptime: section_value(sections, "uptime").unwrap_or_default(), + } +} + +fn parse_hardware(sections: &BTreeMap>) -> HardwareSnapshot { + HardwareSnapshot { + cpu_model: section_value(sections, "cpu_model").unwrap_or_default(), + cpu_cores: section_value(sections, "cpu_cores").unwrap_or_default(), + load_average: section_value(sections, "load_average").unwrap_or_default(), + memory: parse_memory(section_value(sections, "memory").as_deref()), + swap: parse_memory(section_value(sections, "swap").as_deref()), + disks: parse_disks(sections), + sensors: sections.get("sensors").cloned().unwrap_or_default(), + } +} + +fn parse_memory(value: Option<&str>) -> MemorySnapshot { + let Some(value) = value else { + return MemorySnapshot::default(); + }; + let parts: Vec<&str> = value.split('|').collect(); + MemorySnapshot { + total: parts.first().copied().unwrap_or_default().to_owned(), + used: parts.get(1).copied().unwrap_or_default().to_owned(), + free: parts.get(2).copied().unwrap_or_default().to_owned(), + available: parts.get(3).copied().unwrap_or_default().to_owned(), + used_percent: parts.get(4).and_then(|value| parse_percent(value)), + } +} + +fn parse_disks(sections: &BTreeMap>) -> Vec { + sections + .get("disks") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 6).then(|| DiskSnapshot { + filesystem: parts[0].to_owned(), + size: parts[1].to_owned(), + used: parts[2].to_owned(), + available: parts[3].to_owned(), + used_percent: parse_percent(parts[4]), + mount: parts[5].to_owned(), + }) + }) + .collect() +} + +fn parse_services(sections: &BTreeMap>) -> Vec { + sections + .get("services") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 4).then(|| { + let active = parts[1].to_owned(); + let sub = parts[2].to_owned(); + let level = if active == "active" { + HealthLevel::Ok + } else if active == "unknown" || active == "inactive" { + HealthLevel::Warning + } else { + HealthLevel::Critical + }; + ServiceSnapshot { + name: parts[0].to_owned(), + active, + sub, + unit_file: parts[3].to_owned(), + level, + } + }) + }) + .collect() +} + +fn parse_probes(sections: &BTreeMap>) -> Vec { + sections + .get("probes") + .into_iter() + .flatten() + .filter_map(|line| { + let parts: Vec<&str> = line.split('|').collect(); + (parts.len() >= 4).then(|| { + let http_code = parts[2].to_owned(); + let elapsed_ms = parts[3].parse().ok(); + let level = if http_code.starts_with('2') { + HealthLevel::Ok + } else if http_code == "000" { + HealthLevel::Critical + } else { + HealthLevel::Critical + }; + ProbeSnapshot { + name: parts[0].to_owned(), + target: parts[1].to_owned(), + http_code, + elapsed_ms, + level, + } + }) + }) + .collect() +} + +fn parse_health_patrol(sections: &BTreeMap>) -> Option { + let line = section_value(sections, "health_patrol")?; + let parts: Vec<&str> = line.split('|').collect(); + let status = parts.first().copied().unwrap_or_default().to_owned(); + let level = match status.as_str() { + "OK" => HealthLevel::Ok, + "WARNING" => HealthLevel::Warning, + "CRITICAL" => HealthLevel::Critical, + _ => HealthLevel::Unknown, + }; + Some(HealthPatrolSnapshot { + status, + checked_at: parts.get(1).copied().unwrap_or_default().to_owned(), + summary: parts.get(2).copied().unwrap_or_default().to_owned(), + level, + }) +} + +fn parse_percent(value: &str) -> Option { + value.trim_end_matches('%').parse().ok() +} + +pub const HEALTH_SCRIPT: &str = r#"set -eu + +print_section() { + printf '==GENARRATIVE_PANEL:%s==\n' "$1" +} + +print_section checked_at +date -Is 2>/dev/null || date + +print_section hostname +hostname 2>/dev/null || true + +print_section kernel +uname -srmo 2>/dev/null || uname -a 2>/dev/null || true + +print_section uptime +uptime -p 2>/dev/null || uptime 2>/dev/null || true + +print_section cpu_model +awk -F: '/model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}' /proc/cpuinfo 2>/dev/null || true + +print_section cpu_cores +nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || true + +print_section load_average +cat /proc/loadavg 2>/dev/null | awk '{print $1" "$2" "$3}' || true + +print_section memory +awk ' + /^MemTotal:/ {total=$2} + /^MemFree:/ {free=$2} + /^MemAvailable:/ {available=$2} + END { + if (total > 0) { + used = total - free + percent = int((used * 100 + total / 2) / total) + printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, available/1048576, percent + } + } +' /proc/meminfo 2>/dev/null || true + +print_section swap +awk ' + /^SwapTotal:/ {total=$2} + /^SwapFree:/ {free=$2} + END { + if (total > 0) { + used = total - free + percent = int((used * 100 + total / 2) / total) + printf "%.1f GiB|%.1f GiB|%.1f GiB|%.1f GiB|%d%%\n", total/1048576, used/1048576, free/1048576, free/1048576, percent + } else { + print "0 GiB|0 GiB|0 GiB|0 GiB|0%" + } + } +' /proc/meminfo 2>/dev/null || true + +print_section disks +for mount in / /var /opt /stdb /data; do + if [ -e "$mount" ]; then + df -hP "$mount" 2>/dev/null | awk 'NR == 2 {print $1"|"$2"|"$3"|"$4"|"$5"|"$6}' + fi +done | awk '!seen[$6]++' + +print_section sensors +if command -v sensors >/dev/null 2>&1; then + sensors 2>/dev/null | sed -n '1,20p' +else + echo "sensors 未安装" +fi + +print_section services +for service in genarrative-api.service spacetimedb.service nginx.service genarrative-health-patrol.timer genarrative-database-backup.timer; do + active=$(systemctl is-active "$service" 2>/dev/null || true) + sub=$(systemctl show "$service" -p SubState --value 2>/dev/null || true) + unit_file=$(systemctl show "$service" -p UnitFileState --value 2>/dev/null || true) + [ -n "$active" ] || active="unknown" + [ -n "$sub" ] || sub="unknown" + [ -n "$unit_file" ] || unit_file="unknown" + printf '%s|%s|%s|%s\n' "$service" "$active" "$sub" "$unit_file" +done + +print_section probes +probe() { + name="$1" + url="$2" + tmp=$(mktemp) + code=$(curl -fsS -m 5 -o /dev/null -w '%{http_code}|%{time_total}' "$url" 2>"$tmp" || true) + if [ -z "$code" ]; then + code="000|0" + fi + http_code=${code%%|*} + time_total=${code#*|} + elapsed_ms=$(awk "BEGIN {printf \"%d\", $time_total * 1000}") + printf '%s|%s|%s|%s\n' "$name" "$url" "$http_code" "$elapsed_ms" + rm -f "$tmp" +} +probe "api:/healthz" "http://127.0.0.1:8082/healthz" +probe "api:/readyz" "http://127.0.0.1:8082/readyz" +probe "spacetimedb:/v1/ping" "http://127.0.0.1:3101/v1/ping" +probe "public:/api/creation-entry/config" "http://127.0.0.1:8082/api/creation-entry/config" +probe "public:/api/runtime/puzzle/gallery" "http://127.0.0.1:8082/api/runtime/puzzle/gallery" + +print_section health_patrol +if [ -r /var/lib/genarrative/health-patrol/status.json ]; then + node -e ' + const fs = require("fs"); + const payload = JSON.parse(fs.readFileSync("/var/lib/genarrative/health-patrol/status.json", "utf8")); + const status = payload.status || "UNKNOWN"; + const checkedAt = payload.checkedAt || ""; + const checks = Array.isArray(payload.checks) ? payload.checks : []; + const summary = checks.filter((check) => check.status && check.status !== "OK").slice(0, 3).map((check) => `${check.name}:${check.status}`).join(","); + console.log(`${status}|${checkedAt}|${summary}`); + ' 2>/dev/null || echo "UNKNOWN||状态文件解析失败" +else + echo "UNKNOWN||未找到 /var/lib/genarrative/health-patrol/status.json" +fi +"#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_report_sections() { + let report = parse_health_report( + r#"==GENARRATIVE_PANEL:checked_at== +2026-06-11T12:00:00+08:00 +==GENARRATIVE_PANEL:hostname== +release +==GENARRATIVE_PANEL:memory== +2.0 GiB|1.0 GiB|1.0 GiB|1.0 GiB|50% +==GENARRATIVE_PANEL:disks== +/dev/sda1|40G|20G|20G|50%|/ +==GENARRATIVE_PANEL:services== +genarrative-api.service|active|running|enabled +spacetimedb.service|failed|failed|enabled +==GENARRATIVE_PANEL:probes== +api:/readyz|http://127.0.0.1:8082/readyz|200|18 +==GENARRATIVE_PANEL:health_patrol== +WARNING|2026-06-11T11:59:00Z|journal:WARNING +"#, + ); + + assert_eq!(report.host.hostname, "release"); + assert_eq!(report.hardware.memory.used_percent, Some(50)); + assert_eq!(report.services.len(), 2); + assert_eq!(report.probes[0].http_code, "200"); + assert_eq!(report.status, HealthLevel::Critical); + } +} diff --git a/server-rs/crates/server-manager-panel/src/lib.rs b/server-rs/crates/server-manager-panel/src/lib.rs new file mode 100644 index 00000000..4b8838f4 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/lib.rs @@ -0,0 +1,5 @@ +pub mod app; +pub mod fonts; +pub mod health; +pub mod remote; +pub mod ssh_config; diff --git a/server-rs/crates/server-manager-panel/src/main.rs b/server-rs/crates/server-manager-panel/src/main.rs new file mode 100644 index 00000000..888f683b --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/main.rs @@ -0,0 +1,21 @@ +use eframe::egui; +use server_manager_panel::app::ServerManagerApp; +use server_manager_panel::fonts::install_cjk_font; + +fn main() -> eframe::Result<()> { + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1180.0, 760.0]) + .with_min_inner_size([920.0, 620.0]), + ..Default::default() + }; + + eframe::run_native( + "Genarrative 服务器管理面板", + native_options, + Box::new(|cc| { + install_cjk_font(&cc.egui_ctx); + Ok(Box::new(ServerManagerApp::default())) + }), + ) +} diff --git a/server-rs/crates/server-manager-panel/src/remote.rs b/server-rs/crates/server-manager-panel/src/remote.rs new file mode 100644 index 00000000..61b9d5b7 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/remote.rs @@ -0,0 +1,231 @@ +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::health::{HEALTH_SCRIPT, ServerHealthReport, parse_health_report}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceAction { + Start, + Stop, + Restart, +} + +impl ServiceAction { + pub fn as_systemctl_arg(self) -> &'static str { + match self { + ServiceAction::Start => "start", + ServiceAction::Stop => "stop", + ServiceAction::Restart => "restart", + } + } + + pub fn label(self) -> &'static str { + match self { + ServiceAction::Start => "启动", + ServiceAction::Stop => "关闭", + ServiceAction::Restart => "重启", + } + } +} + +#[derive(Debug, Clone)] +pub struct RemoteCommandResult { + pub success: bool, + pub summary: String, + pub stdout: String, + pub stderr: String, +} + +#[derive(Debug)] +pub enum RemoteEvent { + Health { + alias: String, + result: Result, + }, + ServiceAction { + alias: String, + service: String, + action: ServiceAction, + result: RemoteCommandResult, + }, +} + +pub type RemoteSender = mpsc::Sender; +pub type RemoteReceiver = mpsc::Receiver; + +pub fn channel() -> (RemoteSender, RemoteReceiver) { + mpsc::channel() +} + +pub fn spawn_health_check(alias: String, tx: RemoteSender) { + thread::spawn(move || { + let result = + run_ssh_script(&alias, HEALTH_SCRIPT, Duration::from_secs(20)).and_then(|output| { + if output.success { + Ok(parse_health_report(&output.stdout)) + } else { + Err(format_remote_error(&output)) + } + }); + let _ = tx.send(RemoteEvent::Health { alias, result }); + }); +} + +pub fn spawn_service_action( + alias: String, + service: String, + action: ServiceAction, + tx: RemoteSender, +) { + thread::spawn(move || { + let result = if is_safe_service_name(&service) { + run_ssh_script( + &alias, + &build_service_action_script(&service, action), + Duration::from_secs(20), + ) + .unwrap_or_else(|error| RemoteCommandResult { + success: false, + summary: error, + stdout: String::new(), + stderr: String::new(), + }) + } else { + RemoteCommandResult { + success: false, + summary: "服务名包含不允许的字符".to_owned(), + stdout: String::new(), + stderr: String::new(), + } + }; + let _ = tx.send(RemoteEvent::ServiceAction { + alias, + service, + action, + result, + }); + }); +} + +pub fn is_safe_service_name(service: &str) -> bool { + !service.is_empty() + && service.len() <= 128 + && service.bytes().all(|byte| { + matches!( + byte, + b'a'..=b'z' + | b'A'..=b'Z' + | b'0'..=b'9' + | b'.' + | b'_' + | b'-' + | b'@' + | b':' + ) + }) +} + +fn build_service_action_script(service: &str, action: ServiceAction) -> String { + format!( + r#"set -eu +service='{service}' +action='{action}' +if [ "$(id -u)" = "0" ]; then + systemctl "$action" "$service" +else + sudo -n systemctl "$action" "$service" +fi +systemctl is-active "$service" || true +systemctl status "$service" --no-pager -l -n 12 || true +"#, + service = service, + action = action.as_systemctl_arg() + ) +} + +fn run_ssh_script( + alias: &str, + script: &str, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let mut child = Command::new("ssh") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ConnectTimeout=8") + .arg(alias) + .arg("sh") + .arg("-s") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("无法启动 ssh: {error}"))?; + + { + // 中文注释:写完脚本后必须关闭 stdin,让远端 `sh -s` 收到 EOF 并开始退出。 + let Some(mut stdin) = child.stdin.take() else { + return Err("无法写入 ssh stdin".to_owned()); + }; + stdin + .write_all(script.as_bytes()) + .map_err(|error| format!("写入远端脚本失败: {error}"))?; + } + + loop { + match child.try_wait() { + Ok(Some(_status)) => { + let output = child + .wait_with_output() + .map_err(|error| format!("读取 ssh 输出失败: {error}"))?; + let success = output.status.success(); + return Ok(RemoteCommandResult { + success, + summary: if success { + "执行成功".to_owned() + } else { + format!("ssh 退出码 {:?}", output.status.code()) + }, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + Ok(None) if started.elapsed() >= timeout => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("ssh 执行超过 {} 秒", timeout.as_secs())); + } + Ok(None) => thread::sleep(Duration::from_millis(80)), + Err(error) => return Err(format!("等待 ssh 进程失败: {error}")), + } + } +} + +fn format_remote_error(result: &RemoteCommandResult) -> String { + let stderr = result.stderr.trim(); + let stdout = result.stdout.trim(); + if !stderr.is_empty() { + format!("{}: {}", result.summary, stderr) + } else if !stdout.is_empty() { + format!("{}: {}", result.summary, stdout) + } else { + result.summary.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn allows_systemd_unit_names_only() { + assert!(is_safe_service_name("genarrative-api.service")); + assert!(is_safe_service_name("worker@1.service")); + assert!(!is_safe_service_name("api.service;rm -rf /")); + assert!(!is_safe_service_name("")); + } +} diff --git a/server-rs/crates/server-manager-panel/src/ssh_config.rs b/server-rs/crates/server-manager-panel/src/ssh_config.rs new file mode 100644 index 00000000..163b1013 --- /dev/null +++ b/server-rs/crates/server-manager-panel/src/ssh_config.rs @@ -0,0 +1,143 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SshAlias { + pub name: String, + pub source: PathBuf, +} + +pub fn discover_ssh_aliases() -> Vec { + let Some(home) = std::env::var_os("HOME") else { + return Vec::new(); + }; + let config_path = PathBuf::from(home).join(".ssh/config"); + discover_from_file(&config_path) +} + +pub fn discover_from_file(path: &Path) -> Vec { + let mut visited = HashSet::new(); + let mut aliases = Vec::new(); + discover_inner(path, &mut visited, &mut aliases); + dedupe_aliases(aliases) +} + +fn discover_inner(path: &Path, visited: &mut HashSet, aliases: &mut Vec) { + let Ok(canonical) = path.canonicalize() else { + return; + }; + if !visited.insert(canonical.clone()) { + return; + } + let Ok(content) = fs::read_to_string(&canonical) else { + return; + }; + + for line in content.lines() { + let trimmed = trim_comment(line); + let mut parts = trimmed.split_whitespace(); + let Some(keyword) = parts.next() else { + continue; + }; + if keyword.eq_ignore_ascii_case("host") { + aliases.extend(parts.filter_map(|name| { + is_concrete_alias(name).then(|| SshAlias { + name: name.to_owned(), + source: canonical.clone(), + }) + })); + } else if keyword.eq_ignore_ascii_case("include") { + for include in parts { + for include_path in expand_include_path(include, canonical.parent()) { + discover_inner(&include_path, visited, aliases); + } + } + } + } +} + +fn dedupe_aliases(aliases: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for alias in aliases { + if seen.insert(alias.name.clone()) { + deduped.push(alias); + } + } + deduped +} + +fn trim_comment(line: &str) -> &str { + line.split('#').next().unwrap_or("").trim() +} + +fn is_concrete_alias(value: &str) -> bool { + !value.is_empty() + && !value.starts_with('-') + && !value.starts_with('!') + && !value.contains('*') + && !value.contains('?') + && !value.contains('%') + && !value.contains('/') +} + +fn expand_include_path(raw: &str, parent: Option<&Path>) -> Vec { + if raw.contains('*') || raw.contains('?') { + // 中文注释:SSH Include 支持复杂 glob;面板只解析普通文件,避免误扫过大目录。 + return Vec::new(); + } + let expanded = if let Some(rest) = raw.strip_prefix("~/") { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join(rest)) + } else { + let path = PathBuf::from(raw); + if path.is_absolute() { + Some(path) + } else { + parent.map(|base| base.join(path)) + } + }; + expanded.into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_parser_ignores_wildcards_and_negations() { + let mut aliases = Vec::new(); + let source = PathBuf::from("/tmp/config"); + for line in [ + "Host dev release *.internal !blocked", + "Host github.com", + "Host ?pattern", + "Host -bad", + ] { + let trimmed = trim_comment(line); + let mut parts = trimmed.split_whitespace(); + let keyword = parts.next().unwrap(); + if keyword.eq_ignore_ascii_case("host") { + aliases.extend(parts.filter_map(|name| { + is_concrete_alias(name).then(|| SshAlias { + name: name.to_owned(), + source: source.clone(), + }) + })); + } + } + + let names: Vec<_> = dedupe_aliases(aliases) + .into_iter() + .map(|alias| alias.name) + .collect(); + assert_eq!(names, ["dev", "release", "github.com"]); + } + + #[test] + fn comment_trimming_keeps_plain_aliases() { + assert_eq!(trim_comment(" Host dev # release host "), "Host dev"); + } +} diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs new file mode 100644 index 00000000..86de9377 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExternalGenerationJobStatus { + Queued, + Running, + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationQueueOverview { + pub pending_count: u32, + pub running_count: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationQueueOverviewResponse { + pub overview: ExternalGenerationQueueOverview, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationJobStatusRecord { + pub operation_id: String, + pub status: ExternalGenerationJobStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u8, + pub error: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationJobStatusResponse { + pub job: ExternalGenerationJobStatusRecord, +} diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 3bc62911..d750be62 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum JumpHopDifficulty { @@ -311,6 +313,8 @@ pub struct JumpHopActionResponse { pub session: JumpHopSessionSnapshotResponse, #[serde(default)] pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 4faea2da..8f21eacb 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -11,6 +11,7 @@ pub mod creation_agent_document_input; pub mod creation_audio; pub mod creation_entry_config; pub mod creative_agent; +pub mod external_generation; pub mod hyper3d; pub mod jump_hop; pub mod llm; diff --git a/server-rs/crates/shared-contracts/src/puzzle_clear.rs b/server-rs/crates/shared-contracts/src/puzzle_clear.rs index 9d2af4f2..615fc5ae 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_clear.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_clear.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PuzzleClearGenerationStatus { @@ -141,6 +143,8 @@ pub struct PuzzleClearActionResponse { pub action_type: PuzzleClearActionType, pub session: PuzzleClearSessionSnapshotResponse, pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs index 422ea650..342d89ca 100644 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum WoodenFishGenerationStatus { @@ -164,6 +166,8 @@ pub struct WoodenFishActionResponse { pub session: WoodenFishSessionSnapshotResponse, #[serde(default)] pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/bark_battle.rs b/server-rs/crates/spacetime-client/src/bark_battle.rs index 99482684..22e0e71c 100644 --- a/server-rs/crates/spacetime-client/src/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/bark_battle.rs @@ -104,9 +104,7 @@ impl SpacetimeClient { if result.ok { Ok(()) } else { - Err(SpacetimeClientError::procedure_failed( - result.error_message, - )) + Err(SpacetimeClientError::procedure_failed(result.error_message)) } }); send_once(&sender, mapped); diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs new file mode 100644 index 00000000..7c2635de --- /dev/null +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -0,0 +1,173 @@ +use super::*; +use crate::mapper::*; + +impl SpacetimeClient { + pub async fn enqueue_external_generation_job( + &self, + input: ExternalGenerationJobEnqueueRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "enqueue_external_generation_job_and_return", + move |connection, sender| { + connection + .procedures() + .enqueue_external_generation_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn claim_external_generation_jobs( + &self, + input: ExternalGenerationJobClaimRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "claim_external_generation_jobs_and_return", + move |connection, sender| { + connection + .procedures() + .claim_external_generation_jobs_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_claim_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn complete_external_generation_job( + &self, + input: ExternalGenerationJobCompleteRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "complete_external_generation_job_and_return", + move |connection, sender| { + connection + .procedures() + .complete_external_generation_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn renew_external_generation_job_lease( + &self, + input: ExternalGenerationJobRenewLeaseRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "renew_external_generation_job_lease_and_return", + move |connection, sender| { + connection + .procedures() + .renew_external_generation_job_lease_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn fail_external_generation_job( + &self, + input: ExternalGenerationJobFailRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "fail_external_generation_job_and_return", + move |connection, sender| { + connection + .procedures() + .fail_external_generation_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_external_generation_job( + &self, + input: ExternalGenerationJobGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_external_generation_job_and_return", + move |connection, sender| { + connection + .procedures() + .get_external_generation_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_external_generation_queue_stats( + &self, + ) -> Result { + self.call_after_connect( + "get_external_generation_queue_stats_and_return", + move |connection, sender| { + connection + .procedures() + .get_external_generation_queue_stats_and_return_then(move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_queue_stats_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index 6274315c..7d985593 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -113,6 +113,55 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_jump_hop_generation_queued( + &self, + session_id: String, + owner_user_id: String, + payload: JumpHopActionRequest, + ) -> Result { + let current = self + .get_jump_hop_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, + JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, + _ => { + return Err(SpacetimeClientError::validation_failed( + "jump-hop queued generation 只支持 compile-draft/regenerate-tiles", + )); + } + }; + let mut base_draft = current.draft.clone(); + if matches!(action_type, JumpHopActionType::RegenerateTiles) + && let Some(draft) = base_draft.as_mut() + { + draft.tile_atlas_asset = None; + draft.tile_assets.clear(); + } + let mut draft = merge_action_into_draft(base_draft, &payload, scope)?; + let profile_id = resolve_jump_hop_profile_id(&draft, &action_type)?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = JumpHopGenerationStatus::Generating; + let session = self + .compile_jump_hop_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(JumpHopActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -233,15 +282,14 @@ impl SpacetimeClient { }; self.call_after_connect("delete_jump_hop_work", move |connection, sender| { - connection.procedures().delete_jump_hop_work_then( - procedure_input, - move |_, result| { + connection + .procedures() + .delete_jump_hop_work_then(procedure_input, move |_, result| { let mapped = result .map_err(SpacetimeClientError::from_sdk_error) .and_then(map_jump_hop_works_procedure_result); send_once(&sender, mapped); - }, - ); + }); }) .await } @@ -805,6 +853,50 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &JumpHopSessionSnapshotResponse, + owner_user_id: &str, + profile_id: &str, + draft: &JumpHopDraftResponse, + now_micros: i64, +) -> Result { + Ok(JumpHopDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: "跳一跳玩家".to_string(), + seed_text: draft.work_title.clone(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + theme_text: Some(draft.theme_text.clone()), + difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), + style_preset: Some(style_to_str(&draft.style_preset).to_string()), + character_prompt: Some(draft.character_prompt.clone()), + tile_prompt: Some(draft.tile_prompt.clone()), + end_mood_prompt: draft.end_mood_prompt.clone(), + character_asset_json: draft + .character_asset + .as_ref() + .map(json_string) + .transpose()?, + tile_atlas_asset_json: draft + .tile_atlas_asset + .as_ref() + .map(json_string) + .transpose()?, + tile_assets_json: Some(json_string(&draft.tile_assets)?), + cover_composite: draft.cover_composite.clone(), + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_update_input( owner_user_id: &str, profile_id: &str, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 7b012dff..fc8fa653 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -32,13 +32,18 @@ pub use mapper::{ CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, EditorProjectRecord, - EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, JumpHopActionRequest, - JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, - JumpHopDraftResponse, JumpHopGalleryCardResponse, - JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, - JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + EditorProjectResourceCreateRecordInput, EditorProjectResourceRecord, + ExternalGenerationJobClaimRecordInput, + ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse, + JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, + JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, + JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, + JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, @@ -68,12 +73,13 @@ pub use mapper::{ PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, - PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleLeaderboardSubmitRecordInput, PuzzleLevelGenerationFailureRecordInput, + PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, + PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, + PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, + PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, + PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, @@ -116,6 +122,8 @@ pub mod big_fish; pub mod combat; pub mod custom_world; pub mod editor_project; +pub mod external_generation; + pub mod inventory; pub mod jump_hop; pub mod match3d; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 2652170a..e87d41bb 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -9,6 +9,8 @@ mod combat; mod common; mod custom_world; mod editor_project; +mod external_generation; + mod inventory; mod jump_hop; mod match3d; @@ -74,6 +76,12 @@ pub use self::common::{ VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelWorkCompileRecordInput, }; +pub use self::external_generation::{ + ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, +}; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, @@ -112,13 +120,13 @@ pub use self::puzzle::{ PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, - PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, - PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, - PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, - PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, + PuzzleLevelGenerationFailureRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, + PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, }; @@ -188,6 +196,10 @@ pub(crate) use self::editor_project::{ map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result, map_editor_project_resource_procedure_result, }; +pub(crate) use self::external_generation::{ + map_external_generation_job_claim_result, map_external_generation_job_procedure_result, + map_external_generation_queue_stats_result, +}; pub(crate) use self::inventory::{ map_runtime_inventory_state_procedure_result, map_runtime_item_reward_item_snapshot, map_runtime_item_reward_item_snapshot_back, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs new file mode 100644 index 00000000..9e372983 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -0,0 +1,255 @@ +use super::*; + +impl From for ExternalGenerationJobEnqueueInput { + fn from(input: ExternalGenerationJobEnqueueRecordInput) -> Self { + Self { + job_id: input.job_id, + dedupe_key: input.dedupe_key, + job_kind: input.job_kind, + owner_user_id: input.owner_user_id, + source_module: input.source_module, + source_entity_id: input.source_entity_id, + request_label: input.request_label, + request_payload_json: input.request_payload_json, + max_attempts: input.max_attempts, + available_at_micros: input.available_at_micros, + created_at_micros: input.created_at_micros, + } + } +} + +impl From for ExternalGenerationJobClaimInput { + fn from(input: ExternalGenerationJobClaimRecordInput) -> Self { + Self { + worker_id: input.worker_id, + limit: input.limit, + lease_expires_at_micros: input.lease_expires_at_micros, + claimed_at_micros: input.claimed_at_micros, + } + } +} + +impl From for ExternalGenerationJobCompleteInput { + fn from(input: ExternalGenerationJobCompleteRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + result_payload_json: input.result_payload_json, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From for ExternalGenerationJobRenewLeaseInput { + fn from(input: ExternalGenerationJobRenewLeaseRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + lease_expires_at_micros: input.lease_expires_at_micros, + renewed_at_micros: input.renewed_at_micros, + } + } +} + +impl From for ExternalGenerationJobFailInput { + fn from(input: ExternalGenerationJobFailRecordInput) -> Self { + Self { + job_id: input.job_id, + worker_id: input.worker_id, + lease_token: input.lease_token, + error_message: input.error_message, + retry_after_micros: input.retry_after_micros, + failed_at_micros: input.failed_at_micros, + } + } +} + +impl From for ExternalGenerationJobGetInput { + fn from(input: ExternalGenerationJobGetRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + } + } +} + +pub(crate) fn map_external_generation_job_procedure_result( + result: ExternalGenerationJobProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let job = result + .job + .ok_or_else(|| SpacetimeClientError::missing_snapshot("external_generation_job 快照"))?; + + Ok(map_external_generation_job_snapshot(job)) +} + +pub(crate) fn map_external_generation_job_claim_result( + result: ExternalGenerationJobProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .jobs + .into_iter() + .map(map_external_generation_job_snapshot) + .collect()) +} + +pub(crate) fn map_external_generation_queue_stats_result( + result: ExternalGenerationQueueStatsProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let stats = result.stats.ok_or_else(|| { + SpacetimeClientError::missing_snapshot("external_generation queue stats 快照") + })?; + + Ok(ExternalGenerationQueueStatsRecord { + pending_count: stats.pending_count, + delayed_pending_count: stats.delayed_pending_count, + claimable_pending_count: stats.claimable_pending_count, + running_active_count: stats.running_active_count, + expired_running_count: stats.expired_running_count, + terminal_count: stats.terminal_count, + claimable_count: stats.claimable_count, + oldest_claimable_age_micros: stats.oldest_claimable_age_micros, + now_micros: stats.now_micros, + }) +} + +fn map_external_generation_job_snapshot( + snapshot: ExternalGenerationJobSnapshot, +) -> ExternalGenerationJobRecord { + ExternalGenerationJobRecord { + job_id: snapshot.job_id, + dedupe_key: snapshot.dedupe_key, + job_kind: snapshot.job_kind, + owner_user_id: snapshot.owner_user_id, + source_module: snapshot.source_module, + source_entity_id: snapshot.source_entity_id, + request_label: snapshot.request_label, + request_payload_json: snapshot.request_payload_json, + status: snapshot.status, + attempt: snapshot.attempt, + max_attempts: snapshot.max_attempts, + last_error_message: snapshot.last_error_message, + worker_id: snapshot.worker_id, + lease_expires_at: snapshot + .lease_expires_at_micros + .map(format_timestamp_micros), + available_at: format_timestamp_micros(snapshot.available_at_micros), + result_payload_json: snapshot.result_payload_json, + created_at: format_timestamp_micros(snapshot.created_at_micros), + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + lease_token: snapshot.lease_token, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobEnqueueRecordInput { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub max_attempts: u32, + pub available_at_micros: i64, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobClaimRecordInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobCompleteRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub result_payload_json: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobRenewLeaseRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobFailRecordInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_message: String, + pub retry_after_micros: i64, + pub failed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobGetRecordInput { + pub job_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobRecord { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub status: String, + pub attempt: u32, + pub max_attempts: u32, + pub last_error_message: Option, + pub worker_id: Option, + pub lease_expires_at: Option, + pub available_at: String, + pub result_payload_json: Option, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub updated_at: String, + pub updated_at_micros: i64, + pub lease_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationQueueStatsRecord { + pub pending_count: u32, + pub delayed_pending_count: u32, + pub claimable_pending_count: u32, + pub running_active_count: u32, + pub expired_running_count: u32, + pub terminal_count: u32, + pub claimable_count: u32, + pub oldest_claimable_age_micros: Option, + pub now_micros: i64, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index d11564de..2ce8b6a9 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -669,6 +669,22 @@ pub struct PuzzleDraftCompileFailureRecordInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleLevelGenerationFailureRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, + pub error_message: String, + pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -679,6 +695,9 @@ pub struct PuzzleGeneratedImagesSaveRecordInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -691,6 +710,9 @@ pub struct PuzzleUiBackgroundSaveRecordInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 1758d826..b5bfb97a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.4.1 (commit 07b52763c9da8d7cf79780db222fec1ffcb84070). +// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -203,6 +203,7 @@ pub mod chapter_progression_snapshot_type; pub mod chapter_progression_table; pub mod chapter_progression_type; pub mod checkpoint_wooden_fish_run_procedure; +pub mod claim_external_generation_jobs_and_return_procedure; pub mod claim_profile_task_reward_and_return_procedure; pub mod claim_puzzle_background_compile_task_procedure; pub mod claim_puzzle_work_point_incentive_procedure; @@ -221,6 +222,7 @@ pub mod compile_visual_novel_work_profile_procedure; pub mod compile_wooden_fish_draft_procedure; pub mod complete_ai_stage_and_return_procedure; pub mod complete_ai_task_and_return_procedure; +pub mod complete_external_generation_job_and_return_procedure; pub mod confirm_asset_object_and_return_procedure; pub mod confirm_asset_object_reducer; pub mod consume_inventory_item_input_type; @@ -359,12 +361,27 @@ pub mod editor_project_snapshot_type; pub mod editor_project_table; pub mod editor_project_type; pub mod editor_project_viewport_snapshot_type; +pub mod enqueue_external_generation_job_and_return_procedure; + pub mod ensure_analytics_date_dimension_for_date_reducer; pub mod equip_inventory_item_input_type; pub mod execute_custom_world_agent_action_procedure; pub mod export_auth_store_snapshot_from_tables_procedure; pub mod export_database_migration_to_file_procedure; +pub mod external_generation_job_claim_input_type; +pub mod external_generation_job_complete_input_type; +pub mod external_generation_job_enqueue_input_type; +pub mod external_generation_job_fail_input_type; +pub mod external_generation_job_get_input_type; +pub mod external_generation_job_procedure_result_type; +pub mod external_generation_job_renew_lease_input_type; +pub mod external_generation_job_snapshot_type; +pub mod external_generation_job_table; +pub mod external_generation_job_type; +pub mod external_generation_queue_stats_procedure_result_type; +pub mod external_generation_queue_stats_snapshot_type; pub mod fail_ai_task_and_return_procedure; +pub mod fail_external_generation_job_and_return_procedure; pub mod finalize_big_fish_agent_message_turn_procedure; pub mod finalize_custom_world_agent_message_turn_procedure; pub mod finalize_match_3_d_agent_message_turn_procedure; @@ -390,6 +407,9 @@ pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; pub mod get_editor_project_and_return_procedure; +pub mod get_external_generation_job_and_return_procedure; +pub mod get_external_generation_queue_stats_and_return_procedure; + pub mod get_jump_hop_agent_session_procedure; pub mod get_jump_hop_leaderboard_procedure; pub mod get_jump_hop_run_procedure; @@ -515,6 +535,7 @@ pub mod list_wooden_fish_works_procedure; pub mod mark_profile_recharge_order_paid_and_return_procedure; pub mod mark_puzzle_clear_level_time_up_procedure; pub mod mark_puzzle_draft_generation_failed_procedure; +pub mod mark_puzzle_level_generation_failed_procedure; pub mod match_3_d_agent_message_finalize_input_type; pub mod match_3_d_agent_message_row_type; pub mod match_3_d_agent_message_snapshot_type; @@ -710,6 +731,7 @@ pub mod puzzle_leaderboard_entry_row_type; pub mod puzzle_leaderboard_entry_table; pub mod puzzle_leaderboard_entry_type; pub mod puzzle_leaderboard_submit_input_type; +pub mod puzzle_level_generation_failure_input_type; pub mod puzzle_merged_group_state_type; pub mod puzzle_piece_state_type; pub mod puzzle_publication_status_type; @@ -794,6 +816,7 @@ pub mod release_puzzle_background_compile_task_procedure; pub mod remix_big_fish_work_procedure; pub mod remix_custom_world_profile_procedure; pub mod remix_puzzle_work_procedure; +pub mod renew_external_generation_job_lease_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; pub mod resolve_combat_action_procedure_result_type; @@ -1337,6 +1360,7 @@ pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot; pub use chapter_progression_table::*; pub use chapter_progression_type::ChapterProgression; pub use checkpoint_wooden_fish_run_procedure::checkpoint_wooden_fish_run; +pub use claim_external_generation_jobs_and_return_procedure::claim_external_generation_jobs_and_return; pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return; pub use claim_puzzle_background_compile_task_procedure::claim_puzzle_background_compile_task; pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; @@ -1355,6 +1379,7 @@ pub use compile_visual_novel_work_profile_procedure::compile_visual_novel_work_p pub use compile_wooden_fish_draft_procedure::compile_wooden_fish_draft; 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_external_generation_job_and_return_procedure::complete_external_generation_job_and_return; pub use confirm_asset_object_and_return_procedure::confirm_asset_object_and_return; pub use confirm_asset_object_reducer::confirm_asset_object; pub use consume_inventory_item_input_type::ConsumeInventoryItemInput; @@ -1493,12 +1518,27 @@ pub use editor_project_snapshot_type::EditorProjectSnapshot; pub use editor_project_table::*; pub use editor_project_type::EditorProject; pub use editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; +pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_generation_job_and_return; + pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date; pub use equip_inventory_item_input_type::EquipInventoryItemInput; pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action; pub use export_auth_store_snapshot_from_tables_procedure::export_auth_store_snapshot_from_tables; pub use export_database_migration_to_file_procedure::export_database_migration_to_file; +pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInput; +pub use external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput; +pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput; +pub use external_generation_job_fail_input_type::ExternalGenerationJobFailInput; +pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; +pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; +pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; +pub use external_generation_job_table::*; +pub use external_generation_job_type::ExternalGenerationJob; +pub use external_generation_queue_stats_procedure_result_type::ExternalGenerationQueueStatsProcedureResult; +pub use external_generation_queue_stats_snapshot_type::ExternalGenerationQueueStatsSnapshot; pub use fail_ai_task_and_return_procedure::fail_ai_task_and_return; +pub use fail_external_generation_job_and_return_procedure::fail_external_generation_job_and_return; pub use finalize_big_fish_agent_message_turn_procedure::finalize_big_fish_agent_message_turn; pub use finalize_custom_world_agent_message_turn_procedure::finalize_custom_world_agent_message_turn; pub use finalize_match_3_d_agent_message_turn_procedure::finalize_match_3_d_agent_message_turn; @@ -1524,6 +1564,9 @@ pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gall pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; pub use get_editor_project_and_return_procedure::get_editor_project_and_return; +pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; +pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; + pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; pub use get_jump_hop_run_procedure::get_jump_hop_run; @@ -1649,6 +1692,7 @@ pub use list_wooden_fish_works_procedure::list_wooden_fish_works; pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return; pub use mark_puzzle_clear_level_time_up_procedure::mark_puzzle_clear_level_time_up; pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed; +pub use mark_puzzle_level_generation_failed_procedure::mark_puzzle_level_generation_failed; pub use match_3_d_agent_message_finalize_input_type::Match3DAgentMessageFinalizeInput; pub use match_3_d_agent_message_row_type::Match3DAgentMessageRow; pub use match_3_d_agent_message_snapshot_type::Match3DAgentMessageSnapshot; @@ -1844,6 +1888,7 @@ pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow; pub use puzzle_leaderboard_entry_table::*; pub use puzzle_leaderboard_entry_type::PuzzleLeaderboardEntry; pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; +pub use puzzle_level_generation_failure_input_type::PuzzleLevelGenerationFailureInput; pub use puzzle_merged_group_state_type::PuzzleMergedGroupState; pub use puzzle_piece_state_type::PuzzlePieceState; pub use puzzle_publication_status_type::PuzzlePublicationStatus; @@ -1928,6 +1973,7 @@ pub use release_puzzle_background_compile_task_procedure::release_puzzle_backgro pub use remix_big_fish_work_procedure::remix_big_fish_work; pub use remix_custom_world_profile_procedure::remix_custom_world_profile; pub use remix_puzzle_work_procedure::remix_puzzle_work; +pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; pub use resolve_combat_action_procedure_result_type::ResolveCombatActionProcedureResult; @@ -2587,6 +2633,8 @@ pub struct DbUpdate { database_migration_operator: __sdk::TableUpdate, editor_project: __sdk::TableUpdate, editor_project_resource: __sdk::TableUpdate, + external_generation_job: __sdk::TableUpdate, + inventory_slot: __sdk::TableUpdate, jump_hop_agent_session: __sdk::TableUpdate, jump_hop_event: __sdk::TableUpdate, @@ -2805,6 +2853,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "editor_project_resource" => db_update.editor_project_resource.append( editor_project_resource_table::parse_table_update(table_update)?, ), + "external_generation_job" => db_update.external_generation_job.append( + external_generation_job_table::parse_table_update(table_update)?, + ), "inventory_slot" => db_update .inventory_slot .append(inventory_slot_table::parse_table_update(table_update)?), @@ -3274,6 +3325,13 @@ impl __sdk::DbUpdate for DbUpdate { &self.editor_project_resource, ) .with_updates_by_pk(|row| &row.resource_id); + diff.external_generation_job = cache + .apply_diff_to_table::( + "external_generation_job", + &self.external_generation_job, + ) + .with_updates_by_pk(|row| &row.job_id); + diff.inventory_slot = cache .apply_diff_to_table::("inventory_slot", &self.inventory_slot) .with_updates_by_pk(|row| &row.slot_id); @@ -3807,6 +3865,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_project_resource" => db_update .editor_project_resource .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "external_generation_job" => db_update + .external_generation_job + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "inventory_slot" => db_update .inventory_slot .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4180,6 +4241,9 @@ impl __sdk::DbUpdate for DbUpdate { "editor_project_resource" => db_update .editor_project_resource .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "external_generation_job" => db_update + .external_generation_job + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "inventory_slot" => db_update .inventory_slot .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4475,6 +4539,8 @@ pub struct AppliedDiff<'r> { database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>, editor_project: __sdk::TableAppliedDiff<'r, EditorProject>, editor_project_resource: __sdk::TableAppliedDiff<'r, EditorProjectResource>, + external_generation_job: __sdk::TableAppliedDiff<'r, ExternalGenerationJob>, + inventory_slot: __sdk::TableAppliedDiff<'r, InventorySlot>, jump_hop_agent_session: __sdk::TableAppliedDiff<'r, JumpHopAgentSessionRow>, jump_hop_event: __sdk::TableAppliedDiff<'r, JumpHopEventRow>, @@ -4765,6 +4831,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.editor_project_resource, event, ); + callbacks.invoke_table_row_callbacks::( + "external_generation_job", + &self.external_generation_job, + event, + ); callbacks.invoke_table_row_callbacks::( "inventory_slot", &self.inventory_slot, @@ -5849,6 +5920,8 @@ impl __sdk::SpacetimeModule for RemoteModule { database_migration_operator_table::register_table(client_cache); editor_project_table::register_table(client_cache); editor_project_resource_table::register_table(client_cache); + external_generation_job_table::register_table(client_cache); + inventory_slot_table::register_table(client_cache); jump_hop_agent_session_table::register_table(client_cache); jump_hop_event_table::register_table(client_cache); @@ -5971,6 +6044,8 @@ impl __sdk::SpacetimeModule for RemoteModule { "database_migration_operator", "editor_project", "editor_project_resource", + "external_generation_job", + "inventory_slot", "jump_hop_agent_session", "jump_hop_event", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_and_return_procedure.rs new file mode 100644 index 00000000..6455c7b2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_external_generation_jobs_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::external_generation_job_claim_input_type::ExternalGenerationJobClaimInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ClaimExternalGenerationJobsAndReturnArgs { + pub input: ExternalGenerationJobClaimInput, +} + +impl __sdk::InModule for ClaimExternalGenerationJobsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_external_generation_jobs_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_external_generation_jobs_and_return { + fn claim_external_generation_jobs_and_return(&self, input: ExternalGenerationJobClaimInput) { + self.claim_external_generation_jobs_and_return_then(input, |_, _| {}); + } + + fn claim_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl claim_external_generation_jobs_and_return for super::RemoteProcedures { + fn claim_external_generation_jobs_and_return_then( + &self, + input: ExternalGenerationJobClaimInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "claim_external_generation_jobs_and_return", + ClaimExternalGenerationJobsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_and_return_procedure.rs new file mode 100644 index 00000000..9c923b96 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/complete_external_generation_job_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::external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CompleteExternalGenerationJobAndReturnArgs { + pub input: ExternalGenerationJobCompleteInput, +} + +impl __sdk::InModule for CompleteExternalGenerationJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `complete_external_generation_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait complete_external_generation_job_and_return { + fn complete_external_generation_job_and_return( + &self, + input: ExternalGenerationJobCompleteInput, + ) { + self.complete_external_generation_job_and_return_then(input, |_, _| {}); + } + + fn complete_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl complete_external_generation_job_and_return for super::RemoteProcedures { + fn complete_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobCompleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "complete_external_generation_job_and_return", + CompleteExternalGenerationJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_and_return_procedure.rs new file mode 100644 index 00000000..cd14e143 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/enqueue_external_generation_job_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::external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct EnqueueExternalGenerationJobAndReturnArgs { + pub input: ExternalGenerationJobEnqueueInput, +} + +impl __sdk::InModule for EnqueueExternalGenerationJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `enqueue_external_generation_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait enqueue_external_generation_job_and_return { + fn enqueue_external_generation_job_and_return(&self, input: ExternalGenerationJobEnqueueInput) { + self.enqueue_external_generation_job_and_return_then(input, |_, _| {}); + } + + fn enqueue_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl enqueue_external_generation_job_and_return for super::RemoteProcedures { + fn enqueue_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobEnqueueInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "enqueue_external_generation_job_and_return", + EnqueueExternalGenerationJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_claim_input_type.rs new file mode 100644 index 00000000..0a2ed3f1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_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 ExternalGenerationJobClaimInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobClaimInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_complete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_complete_input_type.rs new file mode 100644 index 00000000..e2a93bec --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_complete_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 ExternalGenerationJobCompleteInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub result_payload_json: Option, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobCompleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs new file mode 100644 index 00000000..760a5c3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_enqueue_input_type.rs @@ -0,0 +1,25 @@ +// 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 ExternalGenerationJobEnqueueInput { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub max_attempts: u32, + pub available_at_micros: i64, + pub created_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobEnqueueInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_type.rs new file mode 100644 index 00000000..54bbaf3c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_fail_input_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobFailInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_message: String, + pub retry_after_micros: i64, + pub failed_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobFailInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs new file mode 100644 index 00000000..9b4bd341 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_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 ExternalGenerationJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for ExternalGenerationJobGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_procedure_result_type.rs new file mode 100644 index 00000000..4f3c0f81 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_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::external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobProcedureResult { + pub ok: bool, + pub job: Option, + pub jobs: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalGenerationJobProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_renew_lease_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_renew_lease_input_type.rs new file mode 100644 index 00000000..4ec70e62 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_renew_lease_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 ExternalGenerationJobRenewLeaseInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationJobRenewLeaseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs new file mode 100644 index 00000000..8449e819 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_snapshot_type.rs @@ -0,0 +1,35 @@ +// 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 ExternalGenerationJobSnapshot { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub status: String, + pub attempt: u32, + pub max_attempts: u32, + pub last_error_message: Option, + pub worker_id: Option, + pub lease_expires_at_micros: Option, + pub available_at_micros: i64, + pub result_payload_json: Option, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub completed_at_micros: Option, + pub updated_at_micros: i64, + pub lease_token: Option, +} + +impl __sdk::InModule for ExternalGenerationJobSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_table.rs new file mode 100644 index 00000000..7318cd49 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_table.rs @@ -0,0 +1,192 @@ +// 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::external_generation_job_type::ExternalGenerationJob; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `external_generation_job`. +/// +/// Obtain a handle from the [`ExternalGenerationJobTableAccess::external_generation_job`] method on [`super::RemoteTables`], +/// like `ctx.db.external_generation_job()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_generation_job().on_insert(...)`. +pub struct ExternalGenerationJobTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `external_generation_job`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ExternalGenerationJobTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ExternalGenerationJobTableHandle`], which mediates access to the table `external_generation_job`. + fn external_generation_job(&self) -> ExternalGenerationJobTableHandle<'_>; +} + +impl ExternalGenerationJobTableAccess for super::RemoteTables { + fn external_generation_job(&self) -> ExternalGenerationJobTableHandle<'_> { + ExternalGenerationJobTableHandle { + imp: self + .imp + .get_table::("external_generation_job"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ExternalGenerationJobInsertCallbackId(__sdk::CallbackId); +pub struct ExternalGenerationJobDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ExternalGenerationJobTableHandle<'ctx> { + type Row = ExternalGenerationJob; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ExternalGenerationJobInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobInsertCallbackId { + ExternalGenerationJobInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ExternalGenerationJobInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ExternalGenerationJobDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobDeleteCallbackId { + ExternalGenerationJobDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ExternalGenerationJobDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ExternalGenerationJobUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ExternalGenerationJobTableHandle<'ctx> { + type UpdateCallbackId = ExternalGenerationJobUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ExternalGenerationJobUpdateCallbackId { + ExternalGenerationJobUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ExternalGenerationJobUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `job_id` unique index on the table `external_generation_job`, +/// which allows point queries on the field of the same name +/// via the [`ExternalGenerationJobJobIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_generation_job().job_id().find(...)`. +pub struct ExternalGenerationJobJobIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ExternalGenerationJobTableHandle<'ctx> { + /// Get a handle on the `job_id` unique index on the table `external_generation_job`. + pub fn job_id(&self) -> ExternalGenerationJobJobIdUnique<'ctx> { + ExternalGenerationJobJobIdUnique { + imp: self.imp.get_unique_constraint::("job_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ExternalGenerationJobJobIdUnique<'ctx> { + /// Find the subscribed row whose `job_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 `dedupe_key` unique index on the table `external_generation_job`, +/// which allows point queries on the field of the same name +/// via the [`ExternalGenerationJobDedupeKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.external_generation_job().dedupe_key().find(...)`. +pub struct ExternalGenerationJobDedupeKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ExternalGenerationJobTableHandle<'ctx> { + /// Get a handle on the `dedupe_key` unique index on the table `external_generation_job`. + pub fn dedupe_key(&self) -> ExternalGenerationJobDedupeKeyUnique<'ctx> { + ExternalGenerationJobDedupeKeyUnique { + imp: self.imp.get_unique_constraint::("dedupe_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ExternalGenerationJobDedupeKeyUnique<'ctx> { + /// Find the subscribed row whose `dedupe_key` 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::("external_generation_job"); + _table.add_unique_constraint::("job_id", |row| &row.job_id); + _table.add_unique_constraint::("dedupe_key", |row| &row.dedupe_key); +} + +#[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 `ExternalGenerationJob`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait external_generation_jobQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ExternalGenerationJob`. + fn external_generation_job(&self) -> __sdk::__query_builder::Table; +} + +impl external_generation_jobQueryTableAccess for __sdk::QueryTableAccessor { + fn external_generation_job(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("external_generation_job") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs new file mode 100644 index 00000000..4a4e0afa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_type.rs @@ -0,0 +1,122 @@ +// 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 ExternalGenerationJob { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub status: String, + pub attempt: u32, + pub max_attempts: u32, + pub last_error_message: Option, + pub worker_id: Option, + pub lease_expires_at: Option<__sdk::Timestamp>, + pub available_at: __sdk::Timestamp, + pub result_payload_json: Option, + pub created_at: __sdk::Timestamp, + pub started_at: Option<__sdk::Timestamp>, + pub completed_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, + pub lease_token: Option, +} + +impl __sdk::InModule for ExternalGenerationJob { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ExternalGenerationJob`. +/// +/// Provides typed access to columns for query building. +pub struct ExternalGenerationJobCols { + pub job_id: __sdk::__query_builder::Col, + pub dedupe_key: __sdk::__query_builder::Col, + pub job_kind: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_module: __sdk::__query_builder::Col, + pub source_entity_id: __sdk::__query_builder::Col, + pub request_label: __sdk::__query_builder::Col, + pub request_payload_json: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub attempt: __sdk::__query_builder::Col, + pub max_attempts: __sdk::__query_builder::Col, + pub last_error_message: __sdk::__query_builder::Col>, + pub worker_id: __sdk::__query_builder::Col>, + pub lease_expires_at: + __sdk::__query_builder::Col>, + pub available_at: __sdk::__query_builder::Col, + pub result_payload_json: __sdk::__query_builder::Col>, + pub created_at: __sdk::__query_builder::Col, + pub started_at: __sdk::__query_builder::Col>, + pub completed_at: __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, + pub lease_token: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for ExternalGenerationJob { + type Cols = ExternalGenerationJobCols; + fn cols(table_name: &'static str) -> Self::Cols { + ExternalGenerationJobCols { + job_id: __sdk::__query_builder::Col::new(table_name, "job_id"), + dedupe_key: __sdk::__query_builder::Col::new(table_name, "dedupe_key"), + job_kind: __sdk::__query_builder::Col::new(table_name, "job_kind"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_module: __sdk::__query_builder::Col::new(table_name, "source_module"), + source_entity_id: __sdk::__query_builder::Col::new(table_name, "source_entity_id"), + request_label: __sdk::__query_builder::Col::new(table_name, "request_label"), + request_payload_json: __sdk::__query_builder::Col::new( + table_name, + "request_payload_json", + ), + status: __sdk::__query_builder::Col::new(table_name, "status"), + attempt: __sdk::__query_builder::Col::new(table_name, "attempt"), + max_attempts: __sdk::__query_builder::Col::new(table_name, "max_attempts"), + last_error_message: __sdk::__query_builder::Col::new(table_name, "last_error_message"), + worker_id: __sdk::__query_builder::Col::new(table_name, "worker_id"), + lease_expires_at: __sdk::__query_builder::Col::new(table_name, "lease_expires_at"), + available_at: __sdk::__query_builder::Col::new(table_name, "available_at"), + result_payload_json: __sdk::__query_builder::Col::new( + table_name, + "result_payload_json", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + started_at: __sdk::__query_builder::Col::new(table_name, "started_at"), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + lease_token: __sdk::__query_builder::Col::new(table_name, "lease_token"), + } + } +} + +/// Indexed column accessor struct for the table `ExternalGenerationJob`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ExternalGenerationJobIxCols { + pub dedupe_key: __sdk::__query_builder::IxCol, + pub job_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub worker_id: __sdk::__query_builder::IxCol>, +} + +impl __sdk::__query_builder::HasIxCols for ExternalGenerationJob { + type IxCols = ExternalGenerationJobIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ExternalGenerationJobIxCols { + dedupe_key: __sdk::__query_builder::IxCol::new(table_name, "dedupe_key"), + job_id: __sdk::__query_builder::IxCol::new(table_name, "job_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + worker_id: __sdk::__query_builder::IxCol::new(table_name, "worker_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ExternalGenerationJob {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_procedure_result_type.rs new file mode 100644 index 00000000..2061d33f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_procedure_result_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}; + +use super::external_generation_queue_stats_snapshot_type::ExternalGenerationQueueStatsSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationQueueStatsProcedureResult { + pub ok: bool, + pub stats: Option, + pub error_message: Option, +} + +impl __sdk::InModule for ExternalGenerationQueueStatsProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_snapshot_type.rs new file mode 100644 index 00000000..ae98e521 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_queue_stats_snapshot_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationQueueStatsSnapshot { + pub pending_count: u32, + pub delayed_pending_count: u32, + pub claimable_pending_count: u32, + pub running_active_count: u32, + pub expired_running_count: u32, + pub terminal_count: u32, + pub claimable_count: u32, + pub oldest_claimable_age_micros: Option, + pub now_micros: i64, +} + +impl __sdk::InModule for ExternalGenerationQueueStatsSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_and_return_procedure.rs new file mode 100644 index 00000000..46c1f884 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/fail_external_generation_job_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::external_generation_job_fail_input_type::ExternalGenerationJobFailInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct FailExternalGenerationJobAndReturnArgs { + pub input: ExternalGenerationJobFailInput, +} + +impl __sdk::InModule for FailExternalGenerationJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `fail_external_generation_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait fail_external_generation_job_and_return { + fn fail_external_generation_job_and_return(&self, input: ExternalGenerationJobFailInput) { + self.fail_external_generation_job_and_return_then(input, |_, _| {}); + } + + fn fail_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobFailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl fail_external_generation_job_and_return for super::RemoteProcedures { + fn fail_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobFailInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "fail_external_generation_job_and_return", + FailExternalGenerationJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs new file mode 100644 index 00000000..e2bc98a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_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::external_generation_job_get_input_type::ExternalGenerationJobGetInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetExternalGenerationJobAndReturnArgs { + pub input: ExternalGenerationJobGetInput, +} + +impl __sdk::InModule for GetExternalGenerationJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_external_generation_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_external_generation_job_and_return { + fn get_external_generation_job_and_return(&self, input: ExternalGenerationJobGetInput) { + self.get_external_generation_job_and_return_then(input, |_, _| {}); + } + + fn get_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_external_generation_job_and_return for super::RemoteProcedures { + fn get_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "get_external_generation_job_and_return", + GetExternalGenerationJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs new file mode 100644 index 00000000..9d7a98a0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_queue_stats_and_return_procedure.rs @@ -0,0 +1,54 @@ +// 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::external_generation_queue_stats_procedure_result_type::ExternalGenerationQueueStatsProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetExternalGenerationQueueStatsAndReturnArgs {} + +impl __sdk::InModule for GetExternalGenerationQueueStatsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_external_generation_queue_stats_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_external_generation_queue_stats_and_return { + fn get_external_generation_queue_stats_and_return(&self) { + self.get_external_generation_queue_stats_and_return_then(|_, _| {}); + } + + fn get_external_generation_queue_stats_and_return_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_external_generation_queue_stats_and_return for super::RemoteProcedures { + fn get_external_generation_queue_stats_and_return_then( + &self, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationQueueStatsProcedureResult>( + "get_external_generation_queue_stats_and_return", + GetExternalGenerationQueueStatsAndReturnArgs {}, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_level_generation_failed_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_level_generation_failed_procedure.rs new file mode 100644 index 00000000..1906e8d8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/mark_puzzle_level_generation_failed_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::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_level_generation_failure_input_type::PuzzleLevelGenerationFailureInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct MarkPuzzleLevelGenerationFailedArgs { + pub input: PuzzleLevelGenerationFailureInput, +} + +impl __sdk::InModule for MarkPuzzleLevelGenerationFailedArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `mark_puzzle_level_generation_failed`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait mark_puzzle_level_generation_failed { + fn mark_puzzle_level_generation_failed(&self, input: PuzzleLevelGenerationFailureInput) { + self.mark_puzzle_level_generation_failed_then(input, |_, _| {}); + } + + fn mark_puzzle_level_generation_failed_then( + &self, + input: PuzzleLevelGenerationFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl mark_puzzle_level_generation_failed for super::RemoteProcedures { + fn mark_puzzle_level_generation_failed_then( + &self, + input: PuzzleLevelGenerationFailureInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "mark_puzzle_level_generation_failed", + MarkPuzzleLevelGenerationFailedArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs index ccda3ff5..cfa6818b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_failure_input_type.rs @@ -11,6 +11,9 @@ pub struct PuzzleDraftCompileFailureInput { pub owner_user_id: String, pub error_message: String, pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleDraftCompileFailureInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_input_type.rs index 3b5f565f..13827725 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_draft_compile_input_type.rs @@ -10,6 +10,9 @@ pub struct PuzzleDraftCompileInput { pub session_id: String, pub owner_user_id: String, pub compiled_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleDraftCompileInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs index f5debfcd..d246f74a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs @@ -13,6 +13,9 @@ pub struct PuzzleGeneratedImagesSaveInput { pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleGeneratedImagesSaveInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_type.rs new file mode 100644 index 00000000..6d96e480 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_level_generation_failure_input_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}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleLevelGenerationFailureInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, + pub error_message: String, + pub failed_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, +} + +impl __sdk::InModule for PuzzleLevelGenerationFailureInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs index 28862433..ebdeea95 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_ui_background_save_input_type.rs @@ -15,6 +15,9 @@ pub struct PuzzleUiBackgroundSaveInput { pub image_src: String, pub image_object_key: Option, pub saved_at_micros: i64, + pub external_generation_job_id: Option, + pub external_generation_worker_id: Option, + pub external_generation_lease_token: Option, } impl __sdk::InModule for PuzzleUiBackgroundSaveInput { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_and_return_procedure.rs new file mode 100644 index 00000000..4cbd45fc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/renew_external_generation_job_lease_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::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; +use super::external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RenewExternalGenerationJobLeaseAndReturnArgs { + pub input: ExternalGenerationJobRenewLeaseInput, +} + +impl __sdk::InModule for RenewExternalGenerationJobLeaseAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `renew_external_generation_job_lease_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait renew_external_generation_job_lease_and_return { + fn renew_external_generation_job_lease_and_return( + &self, + input: ExternalGenerationJobRenewLeaseInput, + ) { + self.renew_external_generation_job_lease_and_return_then(input, |_, _| {}); + } + + fn renew_external_generation_job_lease_and_return_then( + &self, + input: ExternalGenerationJobRenewLeaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl renew_external_generation_job_lease_and_return for super::RemoteProcedures { + fn renew_external_generation_job_lease_and_return_then( + &self, + input: ExternalGenerationJobRenewLeaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "renew_external_generation_job_lease_and_return", + RenewExternalGenerationJobLeaseAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 8916e853..080e1353 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -149,10 +149,56 @@ impl SpacetimeClient { owner_user_id: String, compiled_at_micros: i64, ) -> Result { + self.compile_puzzle_agent_draft_inner( + session_id, + owner_user_id, + compiled_at_micros, + (None, None, None), + ) + .await + } + + pub async fn compile_puzzle_agent_draft_with_external_generation_guard( + &self, + session_id: String, + owner_user_id: String, + compiled_at_micros: i64, + external_generation_job_id: Option, + external_generation_worker_id: Option, + external_generation_lease_token: Option, + ) -> Result { + self.compile_puzzle_agent_draft_inner( + session_id, + owner_user_id, + compiled_at_micros, + ( + external_generation_job_id, + external_generation_worker_id, + external_generation_lease_token, + ), + ) + .await + } + + async fn compile_puzzle_agent_draft_inner( + &self, + session_id: String, + owner_user_id: String, + compiled_at_micros: i64, + external_generation_guard: (Option, Option, Option), + ) -> Result { + let ( + external_generation_job_id, + external_generation_worker_id, + external_generation_lease_token, + ) = external_generation_guard; let procedure_input = PuzzleDraftCompileInput { session_id, owner_user_id, compiled_at_micros, + external_generation_job_id, + external_generation_worker_id, + external_generation_lease_token, }; self.call_after_connect("compile_puzzle_agent_draft", move |connection, sender| { @@ -178,6 +224,9 @@ impl SpacetimeClient { owner_user_id: input.owner_user_id, error_message: input.error_message, failed_at_micros: input.failed_at_micros, + external_generation_job_id: input.external_generation_job_id, + external_generation_worker_id: input.external_generation_worker_id, + external_generation_lease_token: input.external_generation_lease_token, }; self.call_after_connect( @@ -196,6 +245,38 @@ impl SpacetimeClient { .await } + pub async fn mark_puzzle_level_generation_failed( + &self, + input: PuzzleLevelGenerationFailureRecordInput, + ) -> Result { + let procedure_input = PuzzleLevelGenerationFailureInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + level_id: input.level_id, + levels_json: input.levels_json, + error_message: input.error_message, + failed_at_micros: input.failed_at_micros, + external_generation_job_id: input.external_generation_job_id, + external_generation_worker_id: input.external_generation_worker_id, + external_generation_lease_token: input.external_generation_lease_token, + }; + + self.call_after_connect( + "mark_puzzle_level_generation_failed", + move |connection, sender| { + connection + .procedures() + .mark_puzzle_level_generation_failed_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + pub async fn claim_puzzle_background_compile_task( &self, input: PuzzleBackgroundCompileTaskClaimRecordInput, @@ -268,6 +349,9 @@ impl SpacetimeClient { levels_json: input.levels_json, candidates_json: input.candidates_json, saved_at_micros: input.saved_at_micros, + external_generation_job_id: input.external_generation_job_id, + external_generation_worker_id: input.external_generation_worker_id, + external_generation_lease_token: input.external_generation_lease_token, }; self.call_after_connect("save_puzzle_generated_images", move |connection, sender| { @@ -297,6 +381,9 @@ impl SpacetimeClient { image_src: input.image_src, image_object_key: input.image_object_key, saved_at_micros: input.saved_at_micros, + external_generation_job_id: input.external_generation_job_id, + external_generation_worker_id: input.external_generation_worker_id, + external_generation_lease_token: input.external_generation_lease_token, }; self.call_after_connect("save_puzzle_ui_background", move |connection, sender| { diff --git a/server-rs/crates/spacetime-client/src/puzzle_clear.rs b/server-rs/crates/spacetime-client/src/puzzle_clear.rs index 8d1dc0ed..ba9876c7 100644 --- a/server-rs/crates/spacetime-client/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-client/src/puzzle_clear.rs @@ -124,6 +124,51 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_puzzle_clear_generation_queued( + &self, + session_id: String, + owner_user_id: String, + author_display_name: String, + payload: PuzzleClearActionRequest, + ) -> Result { + let current = self + .get_puzzle_clear_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + PuzzleClearActionType::CompileDraft => PuzzleClearDraftMergeScope::CompileDraft, + PuzzleClearActionType::RegenerateAtlas => PuzzleClearDraftMergeScope::RegenerateAtlas, + _ => { + return Err(SpacetimeClientError::validation_failed( + "puzzle-clear queued generation 只支持 compile-draft/regenerate-atlas", + )); + } + }; + let mut draft = merge_action_into_draft(current.draft.clone(), &payload, scope)?; + let profile_id = + resolve_puzzle_clear_profile_id(&draft, &action_type, payload.profile_id.as_deref())?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = PuzzleClearGenerationStatus::Generating; + let session = self + .compile_puzzle_clear_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &author_display_name, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(PuzzleClearActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -647,6 +692,38 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &PuzzleClearSessionSnapshotResponse, + owner_user_id: &str, + author_display_name: &str, + profile_id: &str, + draft: &PuzzleClearDraftResponse, + now_micros: i64, +) -> Result { + Ok(PuzzleClearDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: non_empty_str(author_display_name) + .unwrap_or_else(|| "拼消消玩家".to_string()), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_prompt: draft.theme_prompt.clone(), + board_background_prompt: draft.board_background_prompt.clone(), + generate_board_background: draft.generate_board_background, + board_background_asset_json: draft + .board_background_asset + .as_ref() + .map(json_string) + .transpose()?, + atlas_asset_json: draft.atlas_asset.as_ref().map(json_string).transpose()?, + pattern_groups_json: Some(json_string(&draft.pattern_groups)?), + card_assets_json: Some(json_string(&draft.card_assets)?), + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_failed_compile_input( current: &PuzzleClearSessionSnapshotResponse, owner_user_id: &str, diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index ddc7f867..66ad6fc2 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -119,6 +119,53 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_wooden_fish_generation_queued( + &self, + session_id: String, + owner_user_id: String, + author_display_name: String, + payload: WoodenFishActionRequest, + ) -> Result { + let current = self + .get_wooden_fish_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + WoodenFishActionType::CompileDraft => WoodenFishDraftMergeScope::CompileDraft, + WoodenFishActionType::RegenerateHitObject => { + WoodenFishDraftMergeScope::RegenerateHitObject + } + _ => { + return Err(SpacetimeClientError::validation_failed( + "wooden-fish queued generation 只支持 compile-draft/regenerate-hit-object", + )); + } + }; + let mut draft = merge_action_into_draft(current.draft.clone(), &payload, scope)?; + let profile_id = + resolve_wooden_fish_profile_id(&draft, &action_type, payload.profile_id.as_deref())?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = WoodenFishGenerationStatus::Generating; + let session = self + .compile_wooden_fish_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &author_display_name, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(WoodenFishActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -689,6 +736,52 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &WoodenFishSessionSnapshotResponse, + owner_user_id: &str, + author_display_name: &str, + profile_id: &str, + draft: &WoodenFishDraftResponse, + now_micros: i64, +) -> Result { + Ok(WoodenFishDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: author_display_name.trim().to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + hit_object_asset_json: draft + .hit_object_asset + .as_ref() + .map(json_string) + .transpose()?, + background_asset_json: draft + .background_asset + .as_ref() + .map(json_string) + .transpose()?, + hit_sound_asset_json: draft + .hit_sound_asset + .as_ref() + .map(json_string) + .transpose()?, + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, + floating_words_json: Some(json_string(&draft.floating_words)?), + cover_image_src: draft.cover_image_src.clone(), + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_failed_compile_input( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs new file mode 100644 index 00000000..0de980e1 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -0,0 +1,936 @@ +use crate::*; + +const EXTERNAL_GENERATION_STATUS_PENDING: &str = "pending"; +const EXTERNAL_GENERATION_STATUS_RUNNING: &str = "running"; +const EXTERNAL_GENERATION_STATUS_COMPLETED: &str = "completed"; +const EXTERNAL_GENERATION_STATUS_FAILED: &str = "failed"; +const EXTERNAL_GENERATION_STATUS_CANCELLED: &str = "cancelled"; + +#[spacetimedb::table( + accessor = external_generation_job, + index( + accessor = by_external_generation_job_status_available, + btree(columns = [status, available_at]) + ), + index( + accessor = by_external_generation_job_worker_id, + btree(columns = [worker_id]) + ), + index( + accessor = by_external_generation_job_source, + btree(columns = [source_module, source_entity_id]) + ), + index( + accessor = by_external_generation_job_owner_user_id, + btree(columns = [owner_user_id]) + ) +)] +#[derive(Clone)] +pub struct ExternalGenerationJob { + #[primary_key] + pub(crate) job_id: String, + #[unique] + pub(crate) dedupe_key: String, + pub(crate) job_kind: String, + pub(crate) owner_user_id: String, + pub(crate) source_module: String, + pub(crate) source_entity_id: String, + pub(crate) request_label: String, + pub(crate) request_payload_json: String, + pub(crate) status: String, + pub(crate) attempt: u32, + pub(crate) max_attempts: u32, + pub(crate) last_error_message: Option, + pub(crate) worker_id: Option, + pub(crate) lease_expires_at: Option, + pub(crate) available_at: Timestamp, + pub(crate) result_payload_json: Option, + pub(crate) created_at: Timestamp, + pub(crate) started_at: Option, + pub(crate) completed_at: Option, + pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) lease_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobEnqueueInput { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub max_attempts: u32, + pub available_at_micros: i64, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobClaimInput { + pub worker_id: String, + pub limit: u32, + pub lease_expires_at_micros: i64, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobRenewLeaseInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub lease_expires_at_micros: i64, + pub renewed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobCompleteInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub result_payload_json: Option, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobFailInput { + pub job_id: String, + pub worker_id: String, + pub lease_token: String, + pub error_message: String, + pub retry_after_micros: i64, + pub failed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobSnapshot { + pub job_id: String, + pub dedupe_key: String, + pub job_kind: String, + pub owner_user_id: String, + pub source_module: String, + pub source_entity_id: String, + pub request_label: String, + pub request_payload_json: String, + pub status: String, + pub attempt: u32, + pub max_attempts: u32, + pub last_error_message: Option, + pub worker_id: Option, + pub lease_expires_at_micros: Option, + pub available_at_micros: i64, + pub result_payload_json: Option, + pub created_at_micros: i64, + pub started_at_micros: Option, + pub completed_at_micros: Option, + pub updated_at_micros: i64, + pub lease_token: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobProcedureResult { + pub ok: bool, + pub job: Option, + pub jobs: Vec, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationQueueStatsSnapshot { + pub pending_count: u32, + pub delayed_pending_count: u32, + pub claimable_pending_count: u32, + pub running_active_count: u32, + pub expired_running_count: u32, + // 中文注释:保留字段兼容已生成 bindings;controller 只按非终态队列压力扩缩容,不每轮扫描历史终态任务。 + pub terminal_count: u32, + pub claimable_count: u32, + pub oldest_claimable_age_micros: Option, + pub now_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationQueueStatsProcedureResult { + pub ok: bool, + pub stats: Option, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn enqueue_external_generation_job_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobEnqueueInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| enqueue_external_generation_job_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn claim_external_generation_jobs_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobClaimInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| claim_external_generation_jobs_tx(tx, input.clone())) { + Ok(jobs) => ExternalGenerationJobProcedureResult { + ok: true, + job: None, + jobs, + error_message: None, + }, + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn complete_external_generation_job_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobCompleteInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| complete_external_generation_job_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn renew_external_generation_job_lease_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobRenewLeaseInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| renew_external_generation_job_lease_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn fail_external_generation_job_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobFailInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| fail_external_generation_job_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn get_external_generation_job_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobGetInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| get_external_generation_job_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + +#[spacetimedb::procedure] +pub fn get_external_generation_queue_stats_and_return( + ctx: &mut ProcedureContext, +) -> ExternalGenerationQueueStatsProcedureResult { + match ctx.try_with_tx(|tx| get_external_generation_queue_stats_tx(tx)) { + Ok(stats) => ExternalGenerationQueueStatsProcedureResult { + ok: true, + stats: Some(stats), + error_message: None, + }, + Err(message) => ExternalGenerationQueueStatsProcedureResult { + ok: false, + stats: None, + error_message: Some(message), + }, + } +} + +fn enqueue_external_generation_job_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobEnqueueInput, +) -> Result { + validate_required("external_generation_job.job_id", &input.job_id)?; + validate_required("external_generation_job.dedupe_key", &input.dedupe_key)?; + validate_required("external_generation_job.job_kind", &input.job_kind)?; + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + validate_required( + "external_generation_job.source_module", + &input.source_module, + )?; + validate_required( + "external_generation_job.source_entity_id", + &input.source_entity_id, + )?; + validate_required( + "external_generation_job.request_label", + &input.request_label, + )?; + validate_required( + "external_generation_job.request_payload_json", + &input.request_payload_json, + )?; + + if let Some(row) = ctx + .db + .external_generation_job() + .dedupe_key() + .find(&input.dedupe_key) + { + return Ok(map_external_generation_job_row(row)); + } + if ctx + .db + .external_generation_job() + .job_id() + .find(&input.job_id) + .is_some() + { + return Err("external_generation_job.job_id 已存在".to_string()); + } + + let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + let available_at = Timestamp::from_micros_since_unix_epoch(input.available_at_micros); + let row = ExternalGenerationJob { + job_id: input.job_id.trim().to_string(), + dedupe_key: input.dedupe_key.trim().to_string(), + job_kind: input.job_kind.trim().to_string(), + owner_user_id: input.owner_user_id.trim().to_string(), + source_module: input.source_module.trim().to_string(), + source_entity_id: input.source_entity_id.trim().to_string(), + request_label: input.request_label.trim().to_string(), + request_payload_json: input.request_payload_json.trim().to_string(), + status: EXTERNAL_GENERATION_STATUS_PENDING.to_string(), + attempt: 0, + max_attempts: input.max_attempts.max(1), + last_error_message: None, + worker_id: None, + lease_expires_at: None, + available_at, + result_payload_json: None, + created_at: now, + started_at: None, + completed_at: None, + updated_at: now, + lease_token: None, + }; + ctx.db.external_generation_job().insert(row.clone()); + Ok(map_external_generation_job_row(row)) +} + +fn claim_external_generation_jobs_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobClaimInput, +) -> Result, String> { + validate_required("external_generation_job.worker_id", &input.worker_id)?; + if input.limit == 0 { + return Ok(Vec::new()); + } + + let claim_time = ctx.timestamp; + let lease_duration_micros = duration_between_micros( + input.lease_expires_at_micros, + input.claimed_at_micros, + "external_generation_job.lease_duration", + )?; + let lease_expires_at = timestamp_after_micros(claim_time, lease_duration_micros); + let worker_id = input.worker_id.trim().to_string(); + let limit = input.limit.min(64) as usize; + let mut candidates = Vec::new(); + + candidates.extend( + ctx.db + .external_generation_job() + .by_external_generation_job_status_available() + .filter(&EXTERNAL_GENERATION_STATUS_PENDING.to_string()) + .filter(|row| is_external_generation_job_claimable(row, claim_time)), + ); + candidates.extend( + ctx.db + .external_generation_job() + .by_external_generation_job_status_available() + .filter(&EXTERNAL_GENERATION_STATUS_RUNNING.to_string()) + .filter(|row| is_external_generation_job_claimable(row, claim_time)), + ); + + candidates.sort_by(|left, right| { + left.available_at + .to_micros_since_unix_epoch() + .cmp(&right.available_at.to_micros_since_unix_epoch()) + .then_with(|| { + left.created_at + .to_micros_since_unix_epoch() + .cmp(&right.created_at.to_micros_since_unix_epoch()) + }) + .then_with(|| left.job_id.cmp(&right.job_id)) + }); + + let mut claimed = Vec::new(); + for mut row in candidates.into_iter().take(limit) { + let next_attempt = row.attempt.saturating_add(1); + let lease_token = build_external_generation_lease_token( + &row.job_id, + &worker_id, + next_attempt, + claim_time, + ); + row.status = EXTERNAL_GENERATION_STATUS_RUNNING.to_string(); + row.worker_id = Some(worker_id.clone()); + row.lease_expires_at = Some(lease_expires_at); + row.lease_token = Some(lease_token); + row.attempt = next_attempt; + if row.started_at.is_none() { + row.started_at = Some(claim_time); + } + row.updated_at = claim_time; + persist_external_generation_job_row(ctx, row.clone()); + claimed.push(map_external_generation_job_row(row)); + } + + Ok(claimed) +} + +fn complete_external_generation_job_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobCompleteInput, +) -> Result { + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + let completed_at = ctx.timestamp; + row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string(); + row.result_payload_json = input + .result_payload_json + .and_then(|value| normalize_optional_text(value.as_str())); + row.lease_expires_at = None; + row.completed_at = Some(completed_at); + row.updated_at = completed_at; + persist_external_generation_job_row(ctx, row.clone()); + Ok(map_external_generation_job_row(row)) +} + +fn get_external_generation_job_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobGetInput, +) -> Result { + validate_required("external_generation_job.job_id", &input.job_id)?; + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&input.job_id.trim().to_string()) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + if row.owner_user_id.trim() != input.owner_user_id.trim() { + return Err("external_generation_job 不存在".to_string()); + } + + Ok(map_external_generation_job_row(row)) +} + +fn renew_external_generation_job_lease_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobRenewLeaseInput, +) -> Result { + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + let renewed_at = ctx.timestamp; + let lease_duration_micros = duration_between_micros( + input.lease_expires_at_micros, + input.renewed_at_micros, + "external_generation_job.lease_duration", + )?; + row.lease_expires_at = Some(timestamp_after_micros(renewed_at, lease_duration_micros)); + row.updated_at = renewed_at; + persist_external_generation_job_row(ctx, row.clone()); + Ok(map_external_generation_job_row(row)) +} + +fn fail_external_generation_job_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobFailInput, +) -> Result { + let error_message = input.error_message.trim(); + if error_message.is_empty() { + return Err("external_generation_job.error_message 不能为空".to_string()); + } + + let mut row = get_worker_owned_external_generation_job( + ctx, + &input.job_id, + &input.worker_id, + &input.lease_token, + )?; + let failed_at = ctx.timestamp; + let retry_delay_micros = duration_between_micros( + input.retry_after_micros, + input.failed_at_micros, + "external_generation_job.retry_delay", + )?; + row.last_error_message = Some(error_message.to_string()); + row.lease_expires_at = None; + row.worker_id = None; + row.lease_token = None; + row.updated_at = failed_at; + + if row.attempt < row.max_attempts { + row.status = EXTERNAL_GENERATION_STATUS_PENDING.to_string(); + row.available_at = timestamp_after_micros(failed_at, retry_delay_micros); + } else { + row.status = EXTERNAL_GENERATION_STATUS_FAILED.to_string(); + row.completed_at = Some(failed_at); + } + + persist_external_generation_job_row(ctx, row.clone()); + Ok(map_external_generation_job_row(row)) +} + +fn get_external_generation_queue_stats_tx( + ctx: &ReducerContext, +) -> Result { + let now = ctx.timestamp; + let now_micros = now.to_micros_since_unix_epoch(); + let mut stats = ExternalGenerationQueueStatsSnapshot { + pending_count: 0, + delayed_pending_count: 0, + claimable_pending_count: 0, + running_active_count: 0, + expired_running_count: 0, + terminal_count: 0, + claimable_count: 0, + oldest_claimable_age_micros: None, + now_micros, + }; + + for row in ctx + .db + .external_generation_job() + .by_external_generation_job_status_available() + .filter(&EXTERNAL_GENERATION_STATUS_PENDING.to_string()) + { + stats.pending_count = stats.pending_count.saturating_add(1); + if is_external_generation_job_claimable(&row, now) { + stats.claimable_pending_count = stats.claimable_pending_count.saturating_add(1); + record_external_generation_claimable_age(&mut stats, &row, now_micros); + } else { + stats.delayed_pending_count = stats.delayed_pending_count.saturating_add(1); + } + } + + for row in ctx + .db + .external_generation_job() + .by_external_generation_job_status_available() + .filter(&EXTERNAL_GENERATION_STATUS_RUNNING.to_string()) + { + if is_external_generation_job_claimable(&row, now) { + stats.expired_running_count = stats.expired_running_count.saturating_add(1); + record_external_generation_claimable_age(&mut stats, &row, now_micros); + } else { + stats.running_active_count = stats.running_active_count.saturating_add(1); + } + } + + stats.claimable_count = stats + .claimable_pending_count + .saturating_add(stats.expired_running_count); + Ok(stats) +} + +pub(crate) fn validate_external_generation_job_lease_for_tx( + ctx: &ReducerContext, + job_id: &str, + worker_id: &str, + lease_token: &str, + expected_job_kinds: &[&str], + expected_owner_user_id: &str, + expected_source_module: &str, + expected_source_entity_ids: &[String], +) -> Result<(), String> { + let row = get_worker_owned_external_generation_job(ctx, job_id, worker_id, lease_token)?; + if !expected_job_kinds.is_empty() + && !expected_job_kinds + .iter() + .any(|expected| row.job_kind.trim() == expected.trim()) + { + return Err("external_generation_job job_kind 与业务写回不匹配".to_string()); + } + if row.owner_user_id.trim() != expected_owner_user_id.trim() { + return Err("external_generation_job owner_user_id 与业务写回不匹配".to_string()); + } + if row.source_module.trim() != expected_source_module.trim() { + return Err("external_generation_job source_module 与业务写回不匹配".to_string()); + } + if !expected_source_entity_ids + .iter() + .any(|expected| row.source_entity_id.trim() == expected.trim()) + { + return Err("external_generation_job source_entity_id 与业务写回不匹配".to_string()); + } + Ok(()) +} + +fn get_worker_owned_external_generation_job( + ctx: &ReducerContext, + job_id: &str, + worker_id: &str, + lease_token: &str, +) -> Result { + validate_required("external_generation_job.job_id", job_id)?; + validate_required("external_generation_job.worker_id", worker_id)?; + validate_required("external_generation_job.lease_token", lease_token)?; + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&job_id.trim().to_string()) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + if row.status != EXTERNAL_GENERATION_STATUS_RUNNING { + return Err("external_generation_job 当前不是 running 状态".to_string()); + } + if !is_external_generation_job_owned_by_worker(&row, worker_id) { + return Err("external_generation_job worker lease 不匹配".to_string()); + } + if !is_external_generation_job_owned_by_lease_token(&row, lease_token) { + return Err("external_generation_job lease token 不匹配".to_string()); + } + if !is_external_generation_job_lease_active(&row, ctx.timestamp) { + return Err("external_generation_job lease 已过期".to_string()); + } + Ok(row) +} + +fn is_external_generation_job_owned_by_worker( + row: &ExternalGenerationJob, + worker_id: &str, +) -> bool { + row.worker_id.as_deref() == Some(worker_id.trim()) +} + +fn is_external_generation_job_owned_by_lease_token( + row: &ExternalGenerationJob, + lease_token: &str, +) -> bool { + row.lease_token.as_deref() == Some(lease_token.trim()) +} + +fn is_external_generation_job_lease_active(row: &ExternalGenerationJob, now: Timestamp) -> bool { + row.lease_expires_at + .map(|lease_expires_at| lease_expires_at > now) + .unwrap_or(false) +} + +fn is_external_generation_job_claimable(row: &ExternalGenerationJob, now: Timestamp) -> bool { + match row.status.as_str() { + EXTERNAL_GENERATION_STATUS_PENDING => row.available_at <= now, + EXTERNAL_GENERATION_STATUS_RUNNING => row + .lease_expires_at + .map(|lease_expires_at| lease_expires_at <= now) + .unwrap_or(true), + EXTERNAL_GENERATION_STATUS_COMPLETED + | EXTERNAL_GENERATION_STATUS_FAILED + | EXTERNAL_GENERATION_STATUS_CANCELLED => false, + _ => false, + } +} + +fn record_external_generation_claimable_age( + stats: &mut ExternalGenerationQueueStatsSnapshot, + row: &ExternalGenerationJob, + now_micros: i64, +) { + let age = now_micros + .saturating_sub(row.available_at.to_micros_since_unix_epoch()) + .max(0); + stats.oldest_claimable_age_micros = Some( + stats + .oldest_claimable_age_micros + .map(|current| current.max(age)) + .unwrap_or(age), + ); +} + +fn persist_external_generation_job_row(ctx: &ReducerContext, row: ExternalGenerationJob) { + ctx.db + .external_generation_job() + .job_id() + .delete(&row.job_id); + ctx.db.external_generation_job().insert(row); +} + +fn map_external_generation_job_row(row: ExternalGenerationJob) -> ExternalGenerationJobSnapshot { + ExternalGenerationJobSnapshot { + job_id: row.job_id, + dedupe_key: row.dedupe_key, + job_kind: row.job_kind, + owner_user_id: row.owner_user_id, + source_module: row.source_module, + source_entity_id: row.source_entity_id, + request_label: row.request_label, + request_payload_json: row.request_payload_json, + status: row.status, + attempt: row.attempt, + max_attempts: row.max_attempts, + last_error_message: row.last_error_message, + worker_id: row.worker_id, + lease_expires_at_micros: row + .lease_expires_at + .map(|value| value.to_micros_since_unix_epoch()), + available_at_micros: row.available_at.to_micros_since_unix_epoch(), + result_payload_json: row.result_payload_json, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + started_at_micros: row + .started_at + .map(|value| value.to_micros_since_unix_epoch()), + completed_at_micros: row + .completed_at + .map(|value| value.to_micros_since_unix_epoch()), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + lease_token: row.lease_token, + } +} + +fn single_external_generation_job_result( + job: ExternalGenerationJobSnapshot, +) -> ExternalGenerationJobProcedureResult { + ExternalGenerationJobProcedureResult { + ok: true, + job: Some(job), + jobs: Vec::new(), + error_message: None, + } +} + +fn failed_external_generation_job_result(message: String) -> ExternalGenerationJobProcedureResult { + ExternalGenerationJobProcedureResult { + ok: false, + job: None, + jobs: Vec::new(), + error_message: Some(message), + } +} + +fn validate_required(field: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{field} 不能为空")); + } + Ok(()) +} + +fn duration_between_micros(later: i64, earlier: i64, field: &str) -> Result { + let duration = later.saturating_sub(earlier); + if duration <= 0 { + return Err(format!("{field} 必须大于 0")); + } + Ok(duration) +} + +fn timestamp_after_micros(timestamp: Timestamp, duration_micros: i64) -> Timestamp { + Timestamp::from_micros_since_unix_epoch( + timestamp + .to_micros_since_unix_epoch() + .saturating_add(duration_micros.max(0)), + ) +} + +fn build_external_generation_lease_token( + job_id: &str, + worker_id: &str, + attempt: u32, + claimed_at: Timestamp, +) -> String { + format!( + "{}:{}:{}:{}", + job_id.trim(), + worker_id.trim(), + attempt, + claimed_at.to_micros_since_unix_epoch() + ) +} + +fn normalize_optional_text(value: &str) -> Option { + let normalized = value.trim(); + if normalized.is_empty() { + None + } else { + Some(normalized.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn external_generation_job_result_failure_is_structured() { + let result = failed_external_generation_job_result("失败".to_string()); + assert!(!result.ok); + assert_eq!(result.error_message.as_deref(), Some("失败")); + assert!(result.jobs.is_empty()); + } + + #[test] + fn pending_job_is_claimable_only_after_available_time() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + row.available_at = micros(1_000); + + assert!(!is_external_generation_job_claimable(&row, micros(999))); + assert!(is_external_generation_job_claimable(&row, micros(1_000))); + } + + #[test] + fn running_job_is_claimable_only_after_lease_expires() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.lease_expires_at = Some(micros(2_000)); + + assert!(!is_external_generation_job_claimable(&row, micros(1_999))); + assert!(is_external_generation_job_claimable(&row, micros(2_000))); + } + + #[test] + fn terminal_job_is_not_claimable() { + for status in [ + EXTERNAL_GENERATION_STATUS_COMPLETED, + EXTERNAL_GENERATION_STATUS_FAILED, + EXTERNAL_GENERATION_STATUS_CANCELLED, + ] { + let row = external_generation_job_fixture(status); + + assert!(!is_external_generation_job_claimable(&row, micros(10_000))); + } + } + + #[test] + fn worker_ownership_requires_matching_trimmed_worker_id() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.worker_id = Some("worker-a".to_string()); + + assert!(is_external_generation_job_owned_by_worker( + &row, + " worker-a " + )); + assert!(!is_external_generation_job_owned_by_worker( + &row, "worker-b" + )); + } + + #[test] + fn worker_ownership_requires_matching_trimmed_lease_token() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.lease_token = Some("job-1:worker-a:1:1000".to_string()); + + assert!(is_external_generation_job_owned_by_lease_token( + &row, + " job-1:worker-a:1:1000 " + )); + assert!(!is_external_generation_job_owned_by_lease_token( + &row, + "job-1:worker-a:2:2000" + )); + } + + #[test] + fn worker_lease_is_active_only_before_expiry() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + row.lease_expires_at = Some(micros(2_000)); + + assert!(is_external_generation_job_lease_active(&row, micros(1_999))); + assert!(!is_external_generation_job_lease_active( + &row, + micros(2_000) + )); + } + + #[test] + fn lease_token_changes_with_claim_attempt() { + let first = + build_external_generation_lease_token("extgen-test", "worker-a", 1, micros(1_000)); + let second = + build_external_generation_lease_token("extgen-test", "worker-a", 2, micros(2_000)); + + assert_ne!(first, second); + } + + #[test] + fn claimable_age_keeps_oldest_available_job() { + let mut stats = ExternalGenerationQueueStatsSnapshot { + pending_count: 0, + delayed_pending_count: 0, + claimable_pending_count: 0, + running_active_count: 0, + expired_running_count: 0, + terminal_count: 0, + claimable_count: 0, + oldest_claimable_age_micros: None, + now_micros: 10_000, + }; + let mut old_job = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_PENDING); + old_job.available_at = micros(1_000); + let mut newer_job = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING); + newer_job.available_at = micros(8_000); + + record_external_generation_claimable_age(&mut stats, &newer_job, 10_000); + record_external_generation_claimable_age(&mut stats, &old_job, 10_000); + + assert_eq!(stats.oldest_claimable_age_micros, Some(9_000)); + } + + #[test] + fn positive_duration_between_client_times_is_preserved() { + assert_eq!( + duration_between_micros(3_500, 1_000, "external_generation_job.lease_duration"), + Ok(2_500), + ); + assert!(duration_between_micros(1_000, 1_000, "duration").is_err()); + } + + fn external_generation_job_fixture(status: &str) -> ExternalGenerationJob { + ExternalGenerationJob { + job_id: "extgen-test".to_string(), + dedupe_key: "puzzle:compile:test".to_string(), + job_kind: "puzzle_compile_draft".to_string(), + owner_user_id: "user-1".to_string(), + source_module: "puzzle".to_string(), + source_entity_id: "session-1".to_string(), + request_label: "拼图首关草稿生成".to_string(), + request_payload_json: r#"{"sessionId":"session-1"}"#.to_string(), + status: status.to_string(), + attempt: 0, + max_attempts: 1, + last_error_message: None, + worker_id: None, + lease_expires_at: None, + available_at: micros(0), + result_payload_json: None, + created_at: micros(0), + started_at: None, + completed_at: None, + updated_at: micros(0), + lease_token: None, + } + } + + fn micros(value: i64) -> Timestamp { + Timestamp::from_micros_since_unix_epoch(value) + } +} diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 0298903f..9a839f33 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -32,6 +32,7 @@ mod custom_world; mod domain_types; mod editor_project_storage; mod entry; +mod external_generation; mod gameplay; mod jump_hop; mod match3d; @@ -53,6 +54,7 @@ pub use custom_world::*; pub use domain_types::*; pub use editor_project_storage::*; pub use entry::*; +pub use external_generation::*; pub use gameplay::*; pub use jump_hop::*; pub use match3d::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 3ff52004..d2c557a4 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -183,6 +183,7 @@ macro_rules! migration_tables { ai_text_chunk, ai_result_reference, ai_task_event, + external_generation_job, runtime_snapshot, runtime_setting, creation_entry_config, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 2106cccb..6fded8c1 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -14,12 +14,12 @@ use module_puzzle::{ PuzzleBackgroundCompileTaskProcedureResult, PuzzleBackgroundCompileTaskReleaseInput, PuzzleDraftCompileFailureInput, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, - PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, - PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, - PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, - PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, - PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, + PuzzleLeaderboardSubmitInput, PuzzleLevelGenerationFailureInput, PuzzlePublicationStatus, + PuzzlePublishInput, PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, + PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, + PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, + PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleUiBackgroundSaveInput, + PuzzleWorkDeleteInput, PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate, build_form_draft_from_seed, @@ -38,10 +38,15 @@ use spacetimedb::{ }; use crate::auth::user_account; +use crate::validate_external_generation_job_lease_for_tx; const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; const PUZZLE_BACKGROUND_COMPILE_TASK_LEASE_MICROS: i64 = 30 * 60 * 1_000_000; const WORK_VISIBLE_DEFAULT: bool = true; +const PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE: &str = "puzzle"; +const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft"; +const PUZZLE_GENERATE_IMAGES_JOB_KIND: &str = "puzzle_generate_images"; +const PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND: &str = "puzzle_generate_ui_background"; /// 拼图 Agent session 真相表。 /// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。 @@ -407,6 +412,25 @@ pub fn mark_puzzle_draft_generation_failed( } } +#[spacetimedb::procedure] +pub fn mark_puzzle_level_generation_failed( + ctx: &mut ProcedureContext, + input: PuzzleLevelGenerationFailureInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| mark_puzzle_level_generation_failed_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session: Some(session), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn claim_puzzle_background_compile_task( ctx: &mut ProcedureContext, @@ -444,7 +468,6 @@ pub fn release_puzzle_background_compile_task( }, } } - /// 保存拼图入口表单草稿。 /// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 #[spacetimedb::procedure] @@ -1035,6 +1058,17 @@ fn compile_puzzle_agent_draft_tx( ctx: &TxContext, input: PuzzleDraftCompileInput, ) -> Result { + validate_optional_puzzle_external_generation_write_guard( + ctx, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), + &[PUZZLE_COMPILE_DRAFT_JOB_KIND], + &input.session_id, + &input.owner_user_id, + None, + "拼图草稿编译外部生成 guard 不完整", + )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; if row.seed_text.trim().is_empty() { return Err("请先填写拼图作品信息".to_string()); @@ -1163,6 +1197,17 @@ fn mark_puzzle_draft_generation_failed_tx( ctx: &TxContext, input: PuzzleDraftCompileFailureInput, ) -> Result { + validate_optional_puzzle_external_generation_write_guard( + ctx, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), + &[PUZZLE_COMPILE_DRAFT_JOB_KIND], + &input.session_id, + &input.owner_user_id, + None, + "拼图草稿失败态外部生成 guard 不完整", + )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); let draft = match deserialize_optional_draft(&row.draft_json)? { @@ -1214,6 +1259,89 @@ fn mark_puzzle_draft_generation_failed_tx( ) } +fn mark_puzzle_level_generation_failed_tx( + ctx: &TxContext, + input: PuzzleLevelGenerationFailureInput, +) -> Result { + validate_optional_puzzle_external_generation_write_guard( + ctx, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), + &[ + PUZZLE_GENERATE_IMAGES_JOB_KIND, + PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND, + ], + &input.session_id, + &input.owner_user_id, + input.level_id.as_deref(), + "拼图关卡失败态外部生成 guard 不完整", + )?; + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros); + let mut draft = match deserialize_optional_draft(&row.draft_json)? { + Some(draft) => draft, + None => { + let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; + let messages = list_session_messages(ctx, &row.session_id); + compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)) + } + }; + if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { + // 中文注释:新增关卡可能还没完成自动保存,失败回写必须以本次 action 快照作为目标集合。 + draft.levels = levels; + } + draft = mark_puzzle_level_generation_failed_draft(draft, input.level_id.as_deref())?; + let next_stage = resolve_failed_puzzle_agent_stage(row.stage, &draft); + upsert_puzzle_draft_work_profile( + ctx, + &row.session_id, + &row.owner_user_id, + &draft, + input.failed_at_micros, + )?; + + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: row.seed_text.clone(), + current_turn: row.current_turn, + progress_percent: row.progress_percent.max(94), + stage: next_stage, + anchor_pack_json: row.anchor_pack_json.clone(), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: Some(input.error_message), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at, + }, + ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + +fn mark_puzzle_level_generation_failed_draft( + draft: PuzzleResultDraft, + level_id: Option<&str>, +) -> Result { + let target_level = + selected_puzzle_level(&draft, level_id).ok_or_else(|| "拼图关卡不存在".to_string())?; + let mut next_level = target_level; + next_level.generation_status = "failed".to_string(); + let mut draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; + module_puzzle::sync_primary_level_fields(&mut draft); + Ok(draft) +} + fn resolve_failed_puzzle_agent_stage( current_stage: PuzzleAgentStage, draft: &PuzzleResultDraft, @@ -1229,6 +1357,63 @@ fn resolve_failed_puzzle_agent_stage( } } +fn validate_puzzle_external_generation_write_guard( + ctx: &TxContext, + job_id: &str, + worker_id: &str, + lease_token: &str, + expected_job_kinds: &[&str], + session_id: &str, + owner_user_id: &str, + level_id: Option<&str>, +) -> Result<(), String> { + let session_entity_id = session_id.trim().to_string(); + let mut source_entity_ids = vec![session_entity_id.clone()]; + if let Some(level_id) = level_id.map(str::trim).filter(|value| !value.is_empty()) { + source_entity_ids.push(format!("{session_entity_id}:{level_id}")); + } + + validate_external_generation_job_lease_for_tx( + ctx.as_ref(), + job_id, + worker_id, + lease_token, + expected_job_kinds, + owner_user_id, + PUZZLE_EXTERNAL_GENERATION_SOURCE_MODULE, + &source_entity_ids, + ) +} + +fn validate_optional_puzzle_external_generation_write_guard( + ctx: &TxContext, + job_id: Option<&str>, + worker_id: Option<&str>, + lease_token: Option<&str>, + expected_job_kinds: &[&str], + session_id: &str, + owner_user_id: &str, + level_id: Option<&str>, + incomplete_message: &str, +) -> Result<(), String> { + match (job_id, worker_id, lease_token) { + (Some(job_id), Some(worker_id), Some(lease_token)) => { + validate_puzzle_external_generation_write_guard( + ctx, + job_id, + worker_id, + lease_token, + expected_job_kinds, + session_id, + owner_user_id, + level_id, + ) + } + (None, None, None) => Ok(()), + _ => Err(incomplete_message.to_string()), + } +} + fn save_puzzle_form_draft_tx( ctx: &TxContext, input: PuzzleFormDraftSaveInput, @@ -1286,6 +1471,20 @@ fn save_puzzle_generated_images_tx( ctx: &TxContext, input: PuzzleGeneratedImagesSaveInput, ) -> Result { + validate_optional_puzzle_external_generation_write_guard( + ctx, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), + &[ + PUZZLE_COMPILE_DRAFT_JOB_KIND, + PUZZLE_GENERATE_IMAGES_JOB_KIND, + ], + &input.session_id, + &input.owner_user_id, + input.level_id.as_deref(), + "拼图图片保存外部生成 guard 不完整", + )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; let previous_primary_level_name = draft.level_name.clone(); @@ -1370,6 +1569,17 @@ fn save_puzzle_ui_background_tx( ctx: &TxContext, input: PuzzleUiBackgroundSaveInput, ) -> Result { + validate_optional_puzzle_external_generation_write_guard( + ctx, + input.external_generation_job_id.as_deref(), + input.external_generation_worker_id.as_deref(), + input.external_generation_lease_token.as_deref(), + &[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND], + &input.session_id, + &input.owner_user_id, + input.level_id.as_deref(), + "拼图 UI 背景保存外部生成 guard 不完整", + )?; let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { @@ -4040,6 +4250,39 @@ mod tests { ); } + #[test] + fn level_generation_failure_only_marks_target_level_failed() { + let anchor_pack = infer_anchor_pack("画面描述:一只猫在雨夜灯牌下回头。", None); + let mut draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some("画面描述:一只猫在雨夜灯牌下回头。"), + ); + draft.levels[0].generation_status = "ready".to_string(); + draft.levels[0].cover_image_src = Some("/generated-puzzle-assets/first.png".to_string()); + let mut second_level = draft.levels[0].clone(); + second_level.level_id = "puzzle-level-2".to_string(); + second_level.level_name = "第二关".to_string(); + second_level.picture_description = "第二关画面".to_string(); + second_level.cover_image_src = None; + second_level.cover_asset_id = None; + second_level.candidates = Vec::new(); + second_level.selected_candidate_id = None; + second_level.generation_status = "generating".to_string(); + draft.levels.push(second_level); + + let failed = mark_puzzle_level_generation_failed_draft(draft, Some("puzzle-level-2")) + .expect("target level should be marked failed"); + + assert_eq!(failed.levels[0].generation_status, "ready"); + assert_eq!( + failed.levels[0].cover_image_src.as_deref(), + Some("/generated-puzzle-assets/first.png") + ); + assert_eq!(failed.levels[1].generation_status, "failed"); + assert_eq!(failed.generation_status, "ready"); + } + #[test] fn puzzle_recommendation_score_prefers_same_author_weight() { let left = PuzzleWorkProfile { diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear.rs b/server-rs/crates/spacetime-module/src/puzzle_clear.rs index ce917767..62b7a42a 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear.rs @@ -345,6 +345,9 @@ fn compile_puzzle_clear_draft_tx( if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) { return mark_puzzle_clear_generation_failed_tx(ctx, input, session); } + if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_GENERATING) { + return mark_puzzle_clear_generation_generating_tx(ctx, input, session); + } let pattern_groups: Vec = input .pattern_groups_json .as_deref() @@ -457,6 +460,71 @@ fn compile_puzzle_clear_draft_tx( ) } +fn mark_puzzle_clear_generation_generating_tx( + ctx: &ReducerContext, + input: PuzzleClearDraftCompileInput, + session: PuzzleClearAgentSessionRow, +) -> Result { + let updated_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let mut draft = if session.draft_json.trim().is_empty() { + None + } else { + parse_json::(&session.draft_json).ok() + } + .unwrap_or_else(|| PuzzleClearDraftSnapshot { + template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), + template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), + profile_id: Some(input.profile_id.clone()), + work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME), + work_description: input.work_description.trim().to_string(), + theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME), + generate_board_background: input.generate_board_background, + board_background_asset: None, + board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt), + card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), + atlas_asset: None, + pattern_groups: Vec::new(), + card_assets: Vec::new(), + generation_status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(), + }); + draft.profile_id = Some(input.profile_id.clone()); + draft.work_title = clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME); + draft.work_description = input.work_description.trim().to_string(); + draft.theme_prompt = clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME); + draft.generate_board_background = input.generate_board_background; + draft.board_background_prompt = + clean_string(&input.board_background_prompt, &input.theme_prompt); + if let Some(board_background_asset) = input + .board_background_asset_json + .as_deref() + .map(parse_json) + .transpose()? + { + draft.board_background_asset = Some(board_background_asset); + } + draft.generation_status = PUZZLE_CLEAR_GENERATION_GENERATING.to_string(); + + replace_session( + ctx, + &session, + PuzzleClearAgentSessionRow { + status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + updated_at, + ..clone_session(&session) + }, + ); + + get_puzzle_clear_agent_session_tx( + ctx, + PuzzleClearAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn mark_puzzle_clear_generation_failed_tx( ctx: &ReducerContext, input: PuzzleClearDraftCompileInput, diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear/types.rs b/server-rs/crates/spacetime-module/src/puzzle_clear/types.rs index 4e3a12ae..74f80897 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear/types.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear/types.rs @@ -6,6 +6,7 @@ pub const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; pub const PUZZLE_CLEAR_PUBLICATION_DRAFT: &str = "draft"; pub const PUZZLE_CLEAR_PUBLICATION_PUBLISHED: &str = "published"; pub const PUZZLE_CLEAR_GENERATION_DRAFT: &str = "draft"; +pub const PUZZLE_CLEAR_GENERATION_GENERATING: &str = "generating"; pub const PUZZLE_CLEAR_GENERATION_READY: &str = "ready"; pub const PUZZLE_CLEAR_GENERATION_FAILED: &str = "failed"; pub const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp"; diff --git a/src/components/AdventureEntityModal.test.tsx b/src/components/AdventureEntityModal.test.tsx index 60c21e80..3fdb2641 100644 --- a/src/components/AdventureEntityModal.test.tsx +++ b/src/components/AdventureEntityModal.test.tsx @@ -1,13 +1,15 @@ /* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import { AnimationState, + type Character, + type CompanionRenderState, type Encounter, - type GameState, type EquipmentLoadout, + type GameState, WorldType, } from '../types'; import { AdventureEntityModal } from './AdventureEntityModal'; @@ -87,6 +89,66 @@ function createEncounter(overrides: Partial = {}): Encounter { }; } +function createPlayerCharacter(): Character { + return { + id: 'player-1', + name: '潮刃客', + title: '试剑者', + description: '测试主角', + backstory: '测试背景', + personality: '冷静', + avatar: '', + portrait: '', + assetFolder: '', + assetVariant: '', + attributes: { + strength: 5, + agility: 5, + intelligence: 5, + spirit: 5, + }, + skills: [ + { + id: 'tide-slash', + name: '潮刃突进', + animation: AnimationState.ATTACK, + damage: 16, + manaCost: 5, + cooldownTurns: 2, + range: 1, + style: 'burst', + buildBuffs: [ + { + id: 'wet-mark', + sourceType: 'skill', + sourceId: 'tide-slash', + name: '潮湿', + tags: ['控制', '潮汐'], + durationTurns: 2, + }, + ], + }, + ], + adventureOpenings: {}, + }; +} + +function createCompanionRenderState( + character: Character, +): CompanionRenderState { + return { + npcId: 'companion-1', + character, + hp: 100, + maxHp: 100, + mana: 20, + maxMana: 20, + skillCooldowns: {}, + animationState: AnimationState.IDLE, + slot: 'upper', + }; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -169,3 +231,226 @@ test('NPC 背包物品空 id 会被规范成稳定渲染 id', () => { ), ).toBe(false); }); + +test('物品空态复用暗色 PlatformEmptyState chrome', () => { + render( + undefined} + />, + ); + + const emptyState = screen.getByText('暂无物品'); + const attributeSection = screen.getByText('属性').closest('section'); + const itemSection = screen.getByText('物品').closest('section'); + + expect(emptyState.className).toContain('platform-empty-state'); + expect(emptyState.className).toContain('border-dashed'); + expect(emptyState.className).toContain('bg-black/20'); + expect(attributeSection?.className).toContain('border-white/10'); + expect(attributeSection?.className).toContain('bg-black/25'); + expect(itemSection?.className).toContain('border-white/10'); + expect(itemSection?.className).toContain('bg-black/25'); + + const levelPanel = screen.getByTestId('player-level-panel'); + + expect(levelPanel.className).toContain('border-amber-300/18'); + expect(levelPanel.className).toContain('bg-amber-500/8'); + expect(levelPanel.className).toContain('rounded-xl'); +}); + +test('最近回响纯展示小卡复用暗色 PlatformSubpanel chrome', () => { + render( + undefined} + />, + ); + + [ + 'recent-consequence-echo', + 'recent-chronicle-echo', + 'recent-carrier-echo', + 'recent-scene-residue-echo', + ].forEach((testId) => { + const panel = screen.getByTestId(testId); + + expect(panel.className).toContain('border-white/10'); + expect(panel.className).toContain('bg-black/25'); + expect(panel.className).toContain('rounded-xl'); + }); +}); + +test('私聊和队友收束复用暗色 tint PlatformSubpanel chrome', () => { + const companionCharacter = createPlayerCharacter(); + + render( + undefined} + onOpenCharacterChat={() => undefined} + />, + ); + + const privateChatPanel = screen.getByTestId('private-chat-panel'); + const companionResolutionEcho = screen.getByTestId( + 'companion-resolution-echo', + ); + const privateChatButton = screen.getByRole('button', { name: '聊天' }); + + expect(privateChatPanel.className).toContain('border-sky-400/18'); + expect(privateChatPanel.className).toContain('bg-sky-500/8'); + expect(privateChatPanel.className).toContain('rounded-[1.35rem]'); + expect(companionResolutionEcho.className).toContain('border-emerald-400/18'); + expect(companionResolutionEcho.className).toContain('bg-emerald-500/8'); + expect(companionResolutionEcho.className).toContain('rounded-xl'); + expect(privateChatButton.className).toContain( + 'platform-action-button--editor-dark', + ); + expect(privateChatButton.className).toContain('rounded-xl'); + expect(privateChatButton.className).toContain('bg-sky-400/15'); + expect(privateChatButton.className).toContain('disabled:bg-black/20'); +}); + +test('技能详情静态标签复用暗色 PlatformPillBadge chrome', () => { + render( + undefined} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /潮刃突进/u })); + + const skillPanel = screen + .getByText('技能详情') + .closest('.pixel-modal-shell') as HTMLElement; + + const deliveryBadge = within(skillPanel).getAllByText('近战')[0]!; + const styleBadge = within(skillPanel).getAllByText('爆发')[0]!; + const buffSummaryBadge = within(skillPanel).getByText('附带 1 个状态标签'); + const buffBadge = within(skillPanel).getByText('潮湿 / 控制、潮汐 / 2 回合'); + const damagePanel = within(skillPanel) + .getByText('伤害') + .closest('section') as HTMLElement; + const descriptionPanel = within(skillPanel) + .getByText(/潮刃突进 属于爆发路线/u) + .closest('section') as HTMLElement; + const buffPanel = within(skillPanel) + .getByText('附带状态标签') + .closest('section') as HTMLElement; + + expect(deliveryBadge.className).toContain('bg-white/6'); + expect(styleBadge.className).toContain('bg-sky-500/10'); + expect(buffSummaryBadge.className).toContain('bg-emerald-500/10'); + expect(buffBadge.className).toContain('rounded-full'); + expect(buffBadge.className).toContain('bg-sky-500/10'); + expect(damagePanel.className).toContain('bg-black/25'); + expect(damagePanel.className).toContain('border-white/10'); + expect(descriptionPanel.className).toContain('bg-black/25'); + expect(buffPanel.className).toContain('bg-black/25'); +}); diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index cf275df8..39dc7970 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -12,9 +12,7 @@ import { resolveCharacterAttributeProfile, } from '../data/attributeResolver'; import { - formatBuildContributionPercent, getBuildContributionAttributeRows, - getBuildContributionQualityLabel, getCompanionBuildDamageBreakdown, getPlayerBuildDamageBreakdown, resolveMonsterOutgoingDamage, @@ -67,11 +65,11 @@ import { CharacterAnimator } from './CharacterAnimator'; import { buildCharacterSkillRenderId, getCharacterDetailSpriteStyle, - getContributionVisualStyle, getSkillDeliveryLabel, getSkillStyleLabel, } from './CharacterInfoHelpers'; import { + BuildContributionDetailPanel, CharacterAttributeGrid, CharacterIdentityBadges, CharacterSkillsList, @@ -79,6 +77,10 @@ import { PlayerLevelProgress, StatusRow, } from './CharacterInfoShared'; +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared'; import type { GameCanvasEntitySelection } from './GameCanvas'; import { HostileNpcAnimator } from './HostileNpcAnimator'; @@ -129,12 +131,28 @@ function estimateNpcMaxMana(character: Character | null) { function Section({ title, children }: { title: string; children: ReactNode }) { return ( -
+
{title}
{children} -
+ + ); +} + +function SkillMetricCard({ label, value }: { label: string; value: number }) { + return ( + +
+ {label} +
+
{value}
+
); } @@ -226,7 +244,9 @@ function buildSelectionRenderKey(selection: GameCanvasEntitySelection | null) { }`; } -function buildStableRenderKey(parts: Array) { +function buildStableRenderKey( + parts: Array, +) { return parts .map((part, index) => { const normalized = String(part ?? '').trim(); @@ -746,29 +766,26 @@ export function AdventureEntityModal({ affinity: companionNpcState?.affinity ?? null, } satisfies CharacterChatTarget) : null; - const inventory = useMemo( - () => { - const rawInventory = - selection?.kind === 'player' - ? gameState.playerInventory - : selection?.kind === 'companion' && companionCharacter - ? buildCharacterInventoryPreviewItems( - companionCharacter, - gameState.worldType, - ) - : (npcState?.inventory ?? []); + const inventory = useMemo(() => { + const rawInventory = + selection?.kind === 'player' + ? gameState.playerInventory + : selection?.kind === 'companion' && companionCharacter + ? buildCharacterInventoryPreviewItems( + companionCharacter, + gameState.worldType, + ) + : (npcState?.inventory ?? []); - return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); - }, - [ - companionCharacter, - gameState.playerInventory, - gameState.worldType, - npcState?.inventory, - selection?.kind, - selectionRenderKey, - ], - ); + return normalizeInventoryItemRenderIds(rawInventory, selectionRenderKey); + }, [ + companionCharacter, + gameState.playerInventory, + gameState.worldType, + npcState?.inventory, + selection?.kind, + selectionRenderKey, + ]); const attributeSchema = resolveAttributeSchema( gameState.worldType, gameState.customWorldProfile, @@ -1072,7 +1089,13 @@ export function AdventureEntityModal({ {selection.kind === 'companion' && companionChatTarget ? (
-
+
@@ -1084,8 +1107,9 @@ export function AdventureEntityModal({ : `好感达到 ${privateChatUnlockAffinity ?? DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY} 后解锁,当前 ${companionNpcState?.affinity ?? 0}。`}
- +
-
+
) : null} @@ -1122,40 +1142,54 @@ export function AdventureEntityModal({
{selectedCompanionResolution && ( -
+ 队友收束: {selectedCompanionResolution.resolutionType} ·{' '} {selectedCompanionResolution.summary} -
+ )} {relatedConsequences.length > 0 && (
{relatedConsequences.map((record, index) => ( -
{record.title} {':'} {record.summary} -
+ ))}
)} {recentChronicleEntries.length > 0 && (
{recentChronicleEntries.map((entry, index) => ( -
{entry.title} @@ -1163,31 +1197,41 @@ export function AdventureEntityModal({
{entry.summary}
-
+ ))}
)} {recentCarrierEchoes.length > 0 && ( -
+ 载体回响:{recentCarrierEchoes.join(';')} -
+ )} {sceneResidues.length > 0 && (
{sceneResidues.map((residue, index) => ( -
{residue.title} {':'} {residue.visibleClue} -
+ ))}
)} @@ -1198,7 +1242,13 @@ export function AdventureEntityModal({
{selection.kind === 'player' ? ( -
+
等级
@@ -1211,7 +1261,7 @@ export function AdventureEntityModal({ normalizedPlayerProgression.xpToNextLevel } /> -
+ ) : null}
setSelectedItemId(item.id)} /> ) : ( -
暂无物品
+ + 暂无物品 + )}
@@ -1320,70 +1376,10 @@ export function AdventureEntityModal({
-
-
-
-
-
-
- 标签概览 -
-
- {selectedContributionRow.label} -
-
-
-
- {getBuildContributionQualityLabel( - selectedContributionRow.bonusDelta, - )} -
-
- 总加成{' '} - {formatBuildContributionPercent( - selectedContributionRow.bonusDelta, - )} -
-
-
-
-
- -
-
- 属性加成 -
- - {selectedContributionAttributes.length > 0 ? ( -
- {selectedContributionAttributes.map((attribute) => ( -
-
- {attribute.label} - - {formatBuildContributionPercent( - attribute.modifierDelta, - )} - -
-
- ))} -
- ) : ( -
- 当前标签还没有可展示的属性适配明细。 -
- )} -
-
+
@@ -1444,63 +1440,52 @@ export function AdventureEntityModal({ } /> ) : ( -
+ {detailCharacter ? '当前未进入具体世界,暂时无法恢复技能预览。' : '该 NPC 当前没有独立技能演示模型,先展示真实技能数据。'} -
+ )}
- + {getSkillDeliveryLabel(selectedSkill)} - - + + {getSkillStyleLabel(selectedSkill)} - + {selectedSkill.buildBuffs?.length ? ( - + 附带 {selectedSkill.buildBuffs.length} 个状态标签 - + ) : null}
-
-
- 伤害 -
-
- {selectedSkill.damage} -
-
-
-
- 法力 -
-
- {selectedSkill.manaCost} -
-
-
-
- 冷却 -
-
- {selectedSkill.cooldownTurns} -
-
-
-
- 距离 -
-
- {selectedSkill.range} -
-
+ + + +
-
+ {selectedSkill.name} 属于{getSkillStyleLabel(selectedSkill)} 路线,通常以{getSkillDeliveryLabel(selectedSkill)}方式出手, 造成 {selectedSkill.damage} 点伤害,消耗{' '} @@ -1509,16 +1494,21 @@ export function AdventureEntityModal({ {selectedSkill.effects?.length ? ` 该技能还会触发 ${selectedSkill.effects.length} 段战斗特效。` : ''} -
+ {selectedSkill.buildBuffs?.length ? ( -
+
附带状态标签
{selectedSkill.buildBuffs.map((buff, index) => ( - {buff.name} / {buff.tags.join('、')} /{' '} {buff.durationTurns} 回合 - + ))}
-
+ ) : null} diff --git a/src/components/AffinityStatusCard.test.tsx b/src/components/AffinityStatusCard.test.tsx new file mode 100644 index 00000000..87681ae2 --- /dev/null +++ b/src/components/AffinityStatusCard.test.tsx @@ -0,0 +1,30 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { AffinityStatusCard } from './AffinityStatusCard'; + +test('renders affinity level with dark platform pill badge tone', () => { + render(); + + const levelBadge = screen.getAllByText('信任')[0]!; + + expect(levelBadge.className).toContain('rounded-full'); + expect(levelBadge.className).toContain('bg-amber-500/10'); + expect(levelBadge.className).toContain('text-amber-100'); +}); + +test('renders affinity summary and progress with dark PlatformSubpanel chrome', () => { + render(); + + const levelPanel = screen.getByText('好感等级').closest('section'); + const progressPanel = screen.getByText('好感进度').closest('section'); + + expect(levelPanel?.className).toContain('border-white/10'); + expect(levelPanel?.className).toContain('bg-black/25'); + expect(levelPanel?.className).toContain('rounded-xl'); + expect(progressPanel?.className).toContain('border-white/10'); + expect(progressPanel?.className).toContain('bg-black/25'); + expect(progressPanel?.className).toContain('sm:p-4'); +}); diff --git a/src/components/AffinityStatusCard.tsx b/src/components/AffinityStatusCard.tsx index 3a48ed80..5a1da1be 100644 --- a/src/components/AffinityStatusCard.tsx +++ b/src/components/AffinityStatusCard.tsx @@ -2,8 +2,12 @@ import { AFFINITY_PROGRESS_MARKERS, AFFINITY_PROGRESS_MAX, AFFINITY_PROGRESS_MIN, + type AffinityLevelId, getAffinityLevelMeta, } from '../data/affinityLevels'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import type { PlatformPillBadgeTone } from './common/platformPillBadgeModel'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; type AffinityProgressMarker = (typeof AFFINITY_PROGRESS_MARKERS)[number]; @@ -45,6 +49,16 @@ function isMarkerReached(marker: AffinityProgressMarker, affinity: number) { return affinity >= marker.value; } +function getAffinityLevelBadgeTone( + levelId: AffinityLevelId, +): PlatformPillBadgeTone { + if (levelId === 'hostile' || levelId === 'close') return 'darkRose'; + if (levelId === 'guarded') return 'darkSoft'; + if (levelId === 'friendly') return 'darkEmerald'; + if (levelId === 'trusted') return 'darkAmber'; + return 'darkSky'; +} + export function AffinityStatusCard({ affinity }: { affinity: number }) { const currentLevel = getAffinityLevelMeta(affinity); const nextLevel = getNextAffinityMarker(affinity); @@ -69,18 +83,20 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { return (
-
+
好感等级
- {currentLevel.label} - + 当前好感 {affinity} @@ -107,9 +123,14 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) {

{currentLevel.description}

-
+ -
+
好感进度
@@ -215,7 +236,7 @@ export function AffinityStatusCard({ affinity }: { affinity: number }) { ); })}
-
+
); } diff --git a/src/components/BackstoryArchive.test.tsx b/src/components/BackstoryArchive.test.tsx new file mode 100644 index 00000000..bbab5588 --- /dev/null +++ b/src/components/BackstoryArchive.test.tsx @@ -0,0 +1,87 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { expect, test } from 'vitest'; + +import { BackstoryArchive } from './BackstoryArchive'; + +test('renders backstory chapter status with dark platform pill badges', () => { + render( + , + ); + + const unlockedBadge = screen.getByText('已解锁'); + const lockedBadge = screen.getByText('需好感 60'); + + expect(unlockedBadge.className).toContain('rounded-full'); + expect(unlockedBadge.className).toContain('bg-amber-500/10'); + expect(lockedBadge.className).toContain('rounded-full'); + expect(lockedBadge.className).toContain('bg-black/20'); +}); + +test('renders public summary and chapters with dark PlatformSubpanel chrome', () => { + render( + , + ); + + const summaryPanel = screen.getByText('公开印象').closest('section'); + const unlockedPanel = screen.getByText('表层来意').closest('section'); + const lockedPanel = screen.getByText('最终底牌').closest('section'); + + expect(summaryPanel?.className).toContain('border-white/10'); + expect(summaryPanel?.className).toContain('bg-black/25'); + expect(unlockedPanel?.className).toContain('border-amber-300/18'); + expect(unlockedPanel?.className).toContain('bg-black/25'); + expect(lockedPanel?.className).toContain('border-white/10'); + expect(lockedPanel?.className).toContain('bg-black/25'); +}); + +test('renders empty archive with editor dark PlatformEmptyState chrome', () => { + render( + , + ); + + const emptyState = screen.getByText('暂无可整理的背景线索。'); + + expect(emptyState.className).toContain('platform-empty-state'); + expect(emptyState.className).toContain('border-dashed'); + expect(emptyState.className).toContain('bg-black/20'); +}); diff --git a/src/components/BackstoryArchive.tsx b/src/components/BackstoryArchive.tsx index a53d3ae4..8e8b5885 100644 --- a/src/components/BackstoryArchive.tsx +++ b/src/components/BackstoryArchive.tsx @@ -1,3 +1,7 @@ +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformPillBadge } from './common/PlatformPillBadge'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; + export type BackstoryUnlockedChapter = { id: string; title: string; @@ -38,58 +42,71 @@ export function BackstoryArchive({
{publicSummary ? ( -
+
公开印象
{publicSummary}
-
+ ) : null} {unlockedChapters.map((chapter) => ( -
{chapter.title}
- + 已解锁 - +
{chapter.content}
-
+ ))} {lockedChapters.map((chapter) => ( -
{chapter.title}
- + 需好感 {chapter.affinityRequired} - +
{chapter.teaser}
-
+ ))} {!publicSummary && totalChapters === 0 ? ( -
+ 暂无可整理的背景线索。 -
+ ) : null}
); diff --git a/src/components/CharacterChatModal.test.tsx b/src/components/CharacterChatModal.test.tsx new file mode 100644 index 00000000..087c7360 --- /dev/null +++ b/src/components/CharacterChatModal.test.tsx @@ -0,0 +1,137 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { expect, test, vi } from 'vitest'; + +import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; +import type { Character } from '../types'; +import { CharacterChatModal } from './CharacterChatModal'; + +function createCharacter(): Character { + return { + id: 'hero', + name: '沈行', + title: '试剑客', + description: '测试角色', + backstory: '测试背景', + avatar: '/hero.png', + portrait: '/hero.png', + assetFolder: 'hero', + assetVariant: 'default', + attributes: { + strength: 10, + agility: 10, + intelligence: 8, + spirit: 9, + }, + personality: '冷静谨慎', + skills: [], + adventureOpenings: {}, + } as Character; +} + +function createModalState( + overrides: Partial = {}, +): CharacterChatModalState { + return { + target: { + character: createCharacter(), + npcId: 'npc-hero', + roleLabel: '队友', + hp: 80, + maxHp: 100, + mana: 24, + maxMana: 30, + }, + draft: '', + messages: [], + suggestions: ['先问问线索'], + summary: '', + isSending: false, + isLoadingSuggestions: false, + error: '暂时无法生成回复。', + ...overrides, + }; +} + +test('角色聊天错误提示复用暗色 PlatformStatusMessage chrome', () => { + render( + , + ); + + const errorMessage = screen.getByText('暂时无法生成回复。'); + + expect(errorMessage.className).toContain('platform-status-message'); + expect(errorMessage.className).toContain('border-amber-300/15'); + expect(errorMessage.className).toContain('bg-amber-500/10'); + expect(errorMessage.className).toContain('text-amber-50/90'); +}); + +test('角色聊天状态、空态和建议复用暗色 UI Kit chrome', () => { + render( + , + ); + + const hpStatus = screen.getByText('生命值 80 / 100'); + const summaryFallback = screen.getByText('你们还没有形成新的私下聊天总结。'); + const emptyHistory = screen.getByText( + '这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。', + ); + const refreshButton = screen.getByRole('button', { name: '换一组' }); + const suggestionButton = screen.getByRole('button', { name: '先问问线索' }); + const draftTextarea = screen.getByPlaceholderText('对沈行说点什么...'); + + expect(hpStatus.className).toContain('border-white/10'); + expect(hpStatus.className).toContain('bg-black/25'); + expect(summaryFallback.className).toContain('border-white/10'); + expect(summaryFallback.className).toContain('bg-black/25'); + expect(emptyHistory.className).toContain('platform-empty-state'); + expect(emptyHistory.className).toContain('border-dashed'); + expect(refreshButton.className).toContain( + 'platform-action-button--editor-dark', + ); + expect(refreshButton.className).toContain('text-[10px]'); + expect(suggestionButton.className).toContain('platform-dark-option-card'); + expect(suggestionButton.className).toContain('border-white/8'); + expect(draftTextarea.className).toContain('platform-text-field--editor-dark'); + expect(draftTextarea.className).toContain('focus:border-sky-300/35'); +}); + +test('角色聊天标题栏内联关闭按钮保持共享关闭行为', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + + render( + , + ); + + const closeButton = screen.getByRole('button', { name: '关闭角色聊天' }); + await user.click(closeButton); + + expect(closeButton.className).toContain('relative'); + expect(closeButton.className).toContain('shrink-0'); + expect(closeButton.getAttribute('title')).toBe('关闭角色聊天'); + expect(onClose).toHaveBeenCalledTimes(1); +}); diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index 101fe663..81a9a2e3 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -3,6 +3,12 @@ import { useEffect, useRef } from 'react'; import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; import { getNineSliceStyle, UI_CHROME } from '../uiAssets'; +import { PlatformActionButton } from './common/PlatformActionButton'; +import { PlatformDarkOptionCard } from './common/PlatformDarkOptionCard'; +import { PlatformEmptyState } from './common/PlatformEmptyState'; +import { PlatformStatusMessage } from './common/PlatformStatusMessage'; +import { PlatformSubpanel } from './common/PlatformSubpanel'; +import { PlatformTextField } from './common/PlatformTextField'; import { PixelCloseButton } from './PixelCloseButton'; interface CharacterChatModalProps { @@ -68,23 +74,45 @@ export function CharacterChatModal({
角色状态
-
+ 生命值 {modal.target.hp} / {modal.target.maxHp} -
-
+ + 内力 {modal.target.mana} / {modal.target.maxMana} -
-
+ + {modal.target.character.personality} -
+
聊天总结
-
+ {modal.summary || '你们还没有形成新的私下聊天总结。'} -
+
@@ -115,51 +143,57 @@ export function CharacterChatModal({ )) ) : ( -
+ 这里会保留你和该角色的私下聊天记录。输入框支持自由发挥,上方三条文本可以帮你快速起句。 -
+ )}
帮你回复
- +
{modal.suggestions.map((suggestion, index) => ( - + ))}
{modal.error && ( -
+ {modal.error} -
+ )}
-