From ba2490dab4d1e6e5a8580c51d49692f52fd358af Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sun, 10 May 2026 21:01:21 +0800 Subject: [PATCH] feat(core): initialize project with access control and AI integration --- .env.example | 6 +- .gitignore | 3 + .mcp.json | 11 + Cargo.lock | 1537 ++++- Cargo.toml | 20 +- apps/app/src/main.rs | 20 +- apps/email/src/main.rs | 10 +- apps/gingress/Cargo.toml | 46 + .../gingress/src/bin/kubectl-gingress/main.rs | 797 +++ .../src/controller/endpoint_watcher.rs | 151 + .../src/controller/ingress_watcher.rs | 372 ++ apps/gingress/src/controller/mod.rs | 88 + apps/gingress/src/controller/reconciler.rs | 233 + .../gingress/src/controller/secret_watcher.rs | 166 + apps/gingress/src/main.rs | 174 + apps/git-hook/Cargo.toml | 3 + apps/git-hook/src/main.rs | 54 +- apps/gitserver/src/main.rs | 13 +- apps/metrics/Cargo.toml | 58 + apps/metrics/src/args.rs | 35 + apps/metrics/src/hotreload.rs | 40 + apps/metrics/src/k8s_discovery.rs | 67 + apps/metrics/src/loki.rs | 69 + apps/metrics/src/main.rs | 569 ++ apps/metrics/src/metrics.rs | 99 + apps/metrics/src/otel.rs | 40 + apps/metrics/src/scrape.rs | 135 + apps/metrics/src/stats_store.rs | 210 + apps/metrics/src/target.rs | 34 + apps/static/Cargo.toml | 2 + apps/static/src/main.rs | 15 +- build.sh | 88 + bun.lock | 1016 +++- deploy/.helmignore | 23 + deploy/Chart.yaml | 6 + deploy/README.md | 198 + deploy/gingress/deployment.yaml | 93 + deploy/gingress/rbac.yaml | 48 + deploy/templates/NOTES.txt | 19 + deploy/templates/_helpers.tpl | 78 + deploy/templates/app/deployment.yaml | 89 + deploy/templates/app/service.yaml | 16 + deploy/templates/email_worker/deployment.yaml | 70 + deploy/templates/email_worker/service.yaml | 16 + deploy/templates/git_hook/deployment.yaml | 78 + deploy/templates/git_hook/service.yaml | 16 + deploy/templates/gitserver/deployment.yaml | 88 + deploy/templates/gitserver/service.yaml | 20 + deploy/templates/hpa.yaml | 26 + deploy/templates/ingress.yaml | 41 + .../metrics_aggregator/deployment.yaml | 70 + .../templates/metrics_aggregator/service.yaml | 16 + deploy/templates/secret.yaml | 1 + deploy/templates/serviceaccount.yaml | 13 + .../templates/static_server/deployment.yaml | 78 + deploy/templates/static_server/service.yaml | 16 + deploy/values.yaml | 182 + docker/app.Dockerfile | 10 + docker/email.Dockerfile | 10 + docker/gingress.Dockerfile | 10 + docker/githook.Dockerfile | 10 + docker/gitserver.Dockerfile | 10 + docker/metrics.Dockerfile | 10 + docker/static.Dockerfile | 10 + libs/agent/Cargo.toml | 1 + libs/agent/agent/service.rs | 6 +- libs/agent/billing.rs | 579 +- libs/agent/chat/chat_execution.rs | 357 ++ libs/agent/chat/message_builder.rs | 39 +- libs/agent/chat/mod.rs | 31 +- libs/agent/chat/nonstreaming_execution.rs | 6 +- libs/agent/chat/react_execution.rs | 22 +- libs/agent/chat/service.rs | 33 +- libs/agent/chat/session_recording.rs | 5 +- libs/agent/chat/streaming_execution.rs | 137 +- libs/agent/client/mod.rs | 6 +- libs/agent/compact/service.rs | 274 +- libs/agent/compact/types.rs | 16 + libs/agent/embed/service.rs | 10 +- libs/agent/error.rs | 6 + libs/agent/lib.rs | 4 +- libs/agent/perception/vector.rs | 8 + libs/agent/react/mod.rs | 31 +- libs/agent/tokent.rs | 36 +- libs/agent/tool/context.rs | 59 + libs/agent/tool/rig_adapter.rs | 34 +- libs/api/Cargo.toml | 8 + libs/api/agent/mod.rs | 2 +- libs/api/auth/ws_token.rs | 12 +- libs/api/build.rs | 225 +- libs/api/chat/handlers/conversation.rs | 180 + libs/api/chat/handlers/fork.rs | 102 + libs/api/chat/handlers/message.rs | 346 ++ libs/api/chat/handlers/mod.rs | 5 + libs/api/chat/handlers/share.rs | 76 + libs/api/chat/handlers/types.rs | 175 + libs/api/chat/mod.rs | 85 + libs/api/chat/stream.rs | 463 ++ libs/api/chat/watch.rs | 158 + libs/api/dist.rs | 38 +- libs/api/frontend.rs | 4 + libs/api/git/init.rs | 24 +- libs/api/git/star.rs | 5 +- libs/api/git/watch.rs | 5 +- libs/api/lib.rs | 6 +- libs/api/openapi.rs | 103 +- libs/api/project/billing.rs | 24 + libs/api/project/members.rs | 105 + libs/api/project/mod.rs | 27 +- libs/api/project/stats.rs | 27 + libs/api/room/draft_and_history.rs | 62 + libs/api/room/mod.rs | 25 +- libs/api/room/room.rs | 36 + libs/api/room/thread.rs | 42 + libs/api/room/ws.rs | 754 --- libs/api/room/ws_handler.rs | 713 --- libs/api/room/ws_types.rs | 651 --- libs/api/room/ws_universal.rs | 571 -- libs/api/route.rs | 4 +- libs/api/user/billing.rs | 57 + libs/api/user/mod.rs | 17 +- libs/api/user/ssh_key.rs | 23 + libs/api/user/summary.rs | 25 + libs/config/database.rs | 6 +- libs/db/Cargo.toml | 4 + libs/db/cache.rs | 60 + libs/db/database.rs | 13 +- libs/fctool/Cargo.toml | 3 + libs/fctool/src/chat_tools/mod.rs | 42 + libs/fctool/src/chat_tools/retract_message.rs | 155 + libs/fctool/src/chat_tools/send_message.rs | 229 + libs/fctool/src/chat_tools/title.rs | 151 + libs/fctool/src/git_tools/repo_util.rs | 22 +- libs/fctool/src/lib.rs | 3 +- libs/gingress-proxy/Cargo.toml | 42 + libs/gingress-proxy/src/config.rs | 191 + .../src/filters/header_inject.rs | 94 + libs/gingress-proxy/src/filters/mod.rs | 115 + libs/gingress-proxy/src/filters/rate_limit.rs | 78 + libs/gingress-proxy/src/filters/real_ip.rs | 60 + .../src/filters/session_sticky.rs | 81 + libs/gingress-proxy/src/filters/ws_upgrade.rs | 57 + libs/gingress-proxy/src/health_checker.rs | 71 + libs/gingress-proxy/src/hot_reload.rs | 66 + libs/gingress-proxy/src/lib.rs | 27 + libs/gingress-proxy/src/load_balancer.rs | 66 + libs/gingress-proxy/src/observability.rs | 82 + libs/gingress-proxy/src/server.rs | 168 + libs/gingress-proxy/src/tls.rs | 118 + libs/git/Cargo.toml | 6 +- libs/git/commit/query.rs | 31 +- libs/git/diff/ops.rs | 38 +- libs/git/hook/embed.rs | 8 + libs/git/hook/mod.rs | 50 +- libs/git/hook/pool/mod.rs | 5 +- libs/git/hook/pool/worker.rs | 18 +- libs/git/ssh/mod.rs | 2 + libs/git/tree/query.rs | 53 +- libs/migrate/lib.rs | 56 +- ...505_000001_create_project_role_priority.rs | 30 + ...m20260508_000001_create_ai_conversation.rs | 23 + .../m20260508_000002_create_ai_message.rs | 23 + ...m20260508_000003_create_ai_message_fork.rs | 23 + ...08_000004_create_ai_shared_conversation.rs | 23 + .../m20260508_000005_create_ai_token_usage.rs | 23 + ...m20260508_000006_extend_ai_conversation.rs | 31 + ...8_000007_create_project_context_setting.rs | 30 + .../m20260509_000001_create_user_billing.rs | 30 + .../m20260509_000002_create_billing_error.rs | 30 + ...m20260509_000003_extend_project_billing.rs | 65 + ...m20260509_000004_add_message_versioning.rs | 24 + ...m20260509_000005_extend_ai_message_fork.rs | 24 + .../m20260509_000006_create_subscription.rs | 30 + ...05_000001_create_project_role_priority.sql | 14 + ...20260508_000001_create_ai_conversation.sql | 27 + .../m20260508_000002_create_ai_message.sql | 27 + ...20260508_000003_create_ai_message_fork.sql | 17 + ...8_000004_create_ai_shared_conversation.sql | 16 + ...m20260508_000005_create_ai_token_usage.sql | 14 + ...20260508_000006_extend_ai_conversation.sql | 8 + ..._000007_create_project_context_setting.sql | 18 + .../m20260509_000001_create_user_billing.sql | 14 + .../m20260509_000002_create_billing_error.sql | 13 + ...20260509_000003_extend_project_billing.sql | 6 + ...20260509_000004_add_message_versioning.sql | 15 + ...20260509_000005_extend_ai_message_fork.sql | 11 + .../m20260509_000006_create_subscription.sql | 31 + libs/models/ai/ai_conversation.rs | 41 + libs/models/ai/ai_message.rs | 37 + libs/models/ai/ai_message_fork.rs | 22 + libs/models/ai/ai_shared_conversation.rs | 23 + libs/models/ai/ai_token_usage.rs | 24 + libs/models/ai/billing_error.rs | 33 + libs/models/ai/mod.rs | 17 +- libs/models/ai/subscription.rs | 63 + libs/models/lib.rs | 17 +- libs/models/projects/mod.rs | 6 +- libs/models/projects/project.rs | 3 +- libs/models/projects/project_billing.rs | 13 + .../projects/project_context_setting.rs | 38 + libs/models/projects/project_role_priority.rs | 23 + libs/models/rooms/room_notifications.rs | 3 - libs/models/users/mod.rs | 2 + libs/models/users/user_billing.rs | 46 + libs/observability/Cargo.toml | 3 +- libs/observability/src/business_metrics.rs | 149 + libs/observability/src/lib.rs | 4 + libs/observability/src/prometheus_exporter.rs | 43 +- libs/observability/src/push.rs | 338 ++ libs/queue/lib.rs | 4 +- libs/queue/nats_client.rs | 7 +- libs/queue/producer.rs | 63 +- libs/queue/types.rs | 49 + libs/queue/worker.rs | 7 +- libs/room/Cargo.toml | 4 + libs/room/src/ai.rs | 11 + libs/room/src/connection/lifecycle.rs | 10 + libs/room/src/connection/mod.rs | 24 +- libs/room/src/connection/persist.rs | 45 +- libs/room/src/connection/pubsub.rs | 228 +- libs/room/src/connection/rate_limit.rs | 3 + libs/room/src/connection/room_ops.rs | 2 + libs/room/src/connection/stream.rs | 148 +- libs/room/src/draft_and_history.rs | 50 +- libs/room/src/helpers.rs | 2 +- libs/room/src/lib.rs | 2 + libs/room/src/message.rs | 54 +- libs/room/src/message_write.rs | 88 +- libs/room/src/metrics.rs | 13 +- libs/room/src/notification_write.rs | 3 - libs/room/src/presence.rs | 267 + libs/room/src/reaction.rs | 15 +- libs/room/src/room.rs | 44 +- libs/room/src/room_ai_queue.rs | 147 +- libs/room/src/room_write.rs | 2 + libs/room/src/service/ai_common.rs | 22 + libs/room/src/service/ai_mode_streaming.rs | 8 +- .../src/service/ai_mode_streaming_post.rs | 49 +- .../room/src/service/ai_react_nonstreaming.rs | 80 +- libs/room/src/service/ai_react_streaming.rs | 15 +- .../src/service/ai_react_streaming_post.rs | 170 +- .../src/service/ai_react_streaming_steps.rs | 32 +- libs/room/src/service/ai_service.rs | 159 +- libs/room/src/service/ai_streaming.rs | 54 +- libs/room/src/service/mod.rs | 83 +- libs/room/src/service/notifications.rs | 3 - libs/room/src/service/process_ai.rs | 103 +- libs/room/src/service/sequence.rs | 10 +- libs/room/src/service/type_convert.rs | 1 + libs/room/src/service/workers.rs | 28 +- libs/room/src/service/workers_spawn.rs | 31 +- libs/room/src/types.rs | 1 - libs/room/src/types_responses.rs | 10 + libs/service/Cargo.toml | 3 +- libs/service/agent/billing.rs | 1 + libs/service/agent/sync.rs | 5 +- libs/service/auth/email.rs | 17 +- libs/service/auth/login.rs | 3 +- libs/service/auth/register.rs | 46 +- libs/service/auth/rsa.rs | 5 +- libs/service/auth/totp.rs | 24 +- libs/service/chat/access.rs | 161 + libs/service/chat/conversation.rs | 261 + libs/service/chat/fork.rs | 68 + libs/service/chat/message.rs | 465 ++ libs/service/chat/mod.rs | 7 + libs/service/chat/share.rs | 69 + libs/service/error.rs | 34 +- libs/service/git/archive.rs | 73 +- libs/service/git/branch.rs | 13 +- libs/service/git/commit.rs | 13 +- libs/service/git/repo.rs | 12 + libs/service/git/star.rs | 6 +- libs/service/git/tag.rs | 111 +- libs/service/git/tree.rs | 5 +- libs/service/git/watch.rs | 6 +- libs/service/issue/comment.rs | 1 + libs/service/issue/issue.rs | 92 +- libs/service/issue/label.rs | 2 +- libs/service/lib.rs | 78 +- libs/service/project/billing.rs | 66 +- libs/service/project/init.rs | 62 +- libs/service/project/like.rs | 6 +- libs/service/project/members.rs | 245 +- libs/service/project/mod.rs | 1 + libs/service/project/repo.rs | 20 +- libs/service/project/stats.rs | 302 + libs/service/project/watch.rs | 6 +- libs/service/pull_request/merge.rs | 218 +- libs/service/pull_request/pull_request.rs | 6 + libs/service/pull_request/review.rs | 1 + libs/service/pull_request/review_comment.rs | 9 +- libs/service/push.rs | 2 +- libs/service/push_helper.rs | 23 +- libs/service/user/billing.rs | 154 + libs/service/user/chpc.rs | 4 +- libs/service/user/mod.rs | 2 + libs/service/user/projects.rs | 25 +- libs/service/user/repository.rs | 12 + libs/service/user/ssh_key.rs | 22 + libs/service/user/summary.rs | 57 + libs/service/utils/mod.rs | 1 - libs/service/utils/workspace.rs | 74 - libs/service/workspace/alert.rs | 48 +- libs/service/ws_token.rs | 17 +- libs/session/lib.rs | 2 +- libs/session/session.rs | 32 +- libs/transport/bus.rs | 45 +- libs/transport/dedup.rs | 19 +- libs/transport/e2e.rs | 8 +- libs/transport/event/message.rs | 9 + libs/transport/handler/dispatch.rs | 5 +- libs/transport/handler/inbound.rs | 769 ++- libs/transport/handler/poll.rs | 86 +- libs/transport/handler/session.rs | 106 +- libs/transport/handler/sse.rs | 29 +- libs/transport/handler/types.rs | 541 +- libs/transport/handler/ws.rs | 345 +- libs/transport/security.rs | 29 +- libs/transport/token.rs | 2 +- libs/transport/unread.rs | 7 +- libs/webhook/Cargo.toml | 20 - libs/webhook/lib.rs | 14 - openapi.json | 4931 ++++++++++------- package.json | 14 +- push.sh | 32 + src/App.tsx | 178 +- src/app/auth/change-password.tsx | 71 +- src/app/auth/forgot-password.tsx | 51 +- src/app/auth/index.ts | 1 + src/app/auth/login.tsx | 85 +- src/app/auth/register.tsx | 79 +- src/app/auth/reset-password.tsx | 59 +- src/app/auth/two-factor.tsx | 76 +- src/app/auth/verify-email.tsx | 85 + src/app/channel/layout.tsx | 57 +- src/app/chat/ChatConversationList.tsx | 250 + src/app/chat/ChatHeader.tsx | 87 + src/app/chat/ChatMessageBubble.tsx | 475 ++ src/app/chat/ChatMessageInput.tsx | 220 + src/app/chat/ChatMessageList.tsx | 371 ++ src/app/chat/ChatModelSelector.tsx | 210 + src/app/chat/ChatPage.tsx | 133 + src/app/chat/ChatPageContext.ts | 22 + src/app/chat/index.ts | 1 + src/app/layout.tsx | 93 +- src/app/me/MeLayout.tsx | 60 + src/app/me/MePage.bak.tsx | 156 + src/app/me/MePage.tsx | 252 +- src/app/me/components/ActivityTimeline.tsx | 96 + src/app/me/components/ContributionHeatmap.tsx | 121 + src/app/me/components/CreateProjectModal.tsx | 205 + src/app/me/components/FollowerCardList.tsx | 43 + src/app/me/components/MeSidebar.tsx | 195 + src/app/me/components/ProfileHeader.tsx | 155 + src/app/me/components/ProjectList.tsx | 83 + src/app/me/components/RepoList.tsx | 75 + src/app/me/components/UserCardList.tsx | 61 + src/app/me/index.ts | 1 + src/app/project/board/BoardColumn.tsx | 96 + src/app/project/board/BoardHeader.tsx | 56 + src/app/project/board/BoardListView.tsx | 168 + src/app/project/board/BoardModals.tsx | 264 + src/app/project/board/BoardPage.tsx | 17 + src/app/project/board/KanbanBoard.tsx | 183 + src/app/project/board/index.ts | 1 + src/app/project/channel/ChannelPage.tsx | 507 ++ src/app/project/channel/RoomSettingsModal.tsx | 155 + src/app/project/channel/index.ts | 2 + src/app/project/channel/page.tsx | 1 + .../project/channel/settings/AiSettings.tsx | 581 ++ .../components/ProjectCreateMenuModal.tsx | 388 ++ src/app/project/index.ts | 17 + .../project/issue-detail/IssueDetailPage.tsx | 383 ++ src/app/project/issue-detail/IssueSidebar.tsx | 332 ++ src/app/project/issue-detail/ReactionBar.tsx | 111 + src/app/project/issue-detail/index.ts | 1 + src/app/project/issues/IssuesPage.tsx | 246 + src/app/project/issues/NewIssuePage.tsx | 154 + src/app/project/issues/index.ts | 2 + src/app/project/layout.tsx | 107 + .../pull-detail/PullRequestDetailPage.tsx | 254 + src/app/project/pull-detail/index.ts | 1 + src/app/project/pulls/PullsPage.tsx | 167 + src/app/project/pulls/index.ts | 1 + src/app/project/repo/RepoDetailPage.tsx | 150 + src/app/project/repo/branches.tsx | 5 + src/app/project/repo/code.tsx | 5 + src/app/project/repo/commit-detail.tsx | 5 + src/app/project/repo/commits.tsx | 5 + src/app/project/repo/index.ts | 2 + src/app/project/repo/pulls.tsx | 5 + .../settings/BranchProtectionSettings.tsx | 408 ++ .../project/repo/settings/GeneralSettings.tsx | 216 + .../repo/settings/RepoSettingsLayout.tsx | 59 + src/app/project/repo/tags.tsx | 5 + src/app/project/repo/tree.tsx | 5 + src/app/project/repos/ReposPage.tsx | 212 + src/app/project/repos/index.ts | 1 + src/app/project/settings/AccessSettings.tsx | 192 + src/app/project/settings/BillingSettings.tsx | 154 + src/app/project/settings/GeneralSettings.tsx | 314 ++ src/app/project/settings/LabelsSettings.tsx | 124 + src/app/project/settings/MembersSettings.tsx | 100 + .../settings/ProjectSettingsLayout.tsx | 93 + .../project/skill-detail/SkillDetailPage.tsx | 107 + src/app/project/skill-detail/index.ts | 1 + src/app/project/skills/SkillsPage.tsx | 97 + src/app/project/skills/index.ts | 1 + src/app/settings/AccessKeysPage.tsx | 186 + src/app/settings/AppearancePage.tsx | 307 + src/app/settings/BillingPage.tsx | 148 + src/app/settings/EmailPage.tsx | 186 + src/app/settings/MyAccountPage.tsx | 317 ++ src/app/settings/NotificationsPage.tsx | 339 ++ src/app/settings/PasswordPage.tsx | 165 + src/app/settings/PushSettingsPage.tsx | 143 + src/app/settings/SettingsLayout.tsx | 146 + src/app/settings/SshKeysPage.tsx | 324 ++ src/app/settings/index.ts | 10 + src/client/aiChatApi.ts | 204 + src/client/api.ts | 310 +- src/client/generated.ts | 513 +- src/client/model/aiConversationListParams.ts | 13 + src/client/model/aiMessageListParams.ts | 13 + .../model/apiResponseAvatarUploadResponse.ts | 13 + .../apiResponseAvatarUploadResponseData.ts | 10 + .../model/apiResponseBillingErrorsResponse.ts | 13 + .../apiResponseBillingErrorsResponseData.ts | 11 + .../model/apiResponseConversationResponse.ts | 13 + .../apiResponseConversationResponseData.ts | 36 + src/client/model/apiResponseForkResponse.ts | 13 + .../model/apiResponseForkResponseData.ts | 15 + .../apiResponseGroupedMemberListResponse.ts | 13 + ...piResponseGroupedMemberListResponseData.ts | 13 + .../model/apiResponseIssueResponseData.ts | 2 + .../model/apiResponseMessageResponse.ts | 13 + .../model/apiResponseMessageResponseData.ts | 34 + ...sponseProjectBillingCurrentResponseData.ts | 1 + .../model/apiResponseRolePriorityInfo.ts | 13 + .../model/apiResponseRolePriorityInfoData.ts | 17 + .../apiResponseRolePriorityListResponse.ts | 13 + ...apiResponseRolePriorityListResponseData.ts | 11 + .../apiResponseRoomMessageResponseData.ts | 2 + src/client/model/apiResponseShareResponse.ts | 13 + .../model/apiResponseShareResponseData.ts | 14 + .../apiResponseUserBillingErrorsResponse.ts | 13 + ...piResponseUserBillingErrorsResponseData.ts | 11 + .../apiResponseUserBillingHistoryResponse.ts | 13 + ...iResponseUserBillingHistoryResponseData.ts | 17 + .../model/apiResponseUserBillingResponse.ts | 13 + .../apiResponseUserBillingResponseData.ts | 21 + .../model/apiResponseUserSummaryResponse.ts | 13 + .../apiResponseUserSummaryResponseData.ts | 25 + .../apiResponseVecConversationResponse.ts | 13 + ...ResponseVecConversationResponseDataItem.ts | 36 + .../model/apiResponseVecMessageResponse.ts | 13 + .../apiResponseVecMessageResponseDataItem.ts | 34 + .../model/apiResponseVecPresenceChanged.ts | 13 + .../apiResponseVecPresenceChangedDataItem.ts | 19 + src/client/model/avatarUploadResponse.ts | 10 + src/client/model/billingErrorItem.ts | 17 + src/client/model/billingErrorsResponse.ts | 11 + src/client/model/billingRecord.ts | 1 + src/client/model/conversationListParams.ts | 13 + src/client/model/conversationResponse.ts | 36 + src/client/model/createConversationParams.ts | 30 + src/client/model/createMessageParams.ts | 20 + src/client/model/forkResponse.ts | 15 + src/client/model/gitUpdateRepoRequest.ts | 6 + src/client/model/groupedMemberListResponse.ts | 13 + src/client/model/index.ts | 104 +- src/client/model/issueResponse.ts | 2 + src/client/model/memberGroup.ts | 12 + src/client/model/messageContent.ts | 11 + src/client/model/messageListParams.ts | 15 + src/client/model/messageResponse.ts | 34 + src/client/model/notificationType.ts | 1 - src/client/model/pager.ts | 2 +- src/client/model/presenceChanged.ts | 19 + src/client/model/presenceStatus.ts | 19 + .../model/projectBillingCurrentResponse.ts | 1 + src/client/model/projectInitParams.ts | 7 +- src/client/model/projectModel.ts | 2 - src/client/model/reactionGroupResponse.ts | 13 + src/client/model/reviewCommentListQuery.ts | 10 + src/client/model/rolePriorityInfo.ts | 17 + src/client/model/rolePriorityListResponse.ts | 11 + src/client/model/roomMessageResponse.ts | 2 + src/client/model/shareResponse.ts | 14 + src/client/model/updateConversationParams.ts | 24 + src/client/model/upsertRolePriorityRequest.ts | 14 + src/client/model/userBillingErrorItem.ts | 17 + src/client/model/userBillingErrorsResponse.ts | 11 + src/client/model/userBillingHistoryItem.ts | 18 + src/client/model/userBillingHistoryParams.ts | 17 + src/client/model/userBillingHistoryQuery.ts | 19 + .../model/userBillingHistoryResponse.ts | 17 + src/client/model/userBillingResponse.ts | 21 + src/client/model/userRepoInfo.ts | 1 + src/client/model/userSummaryResponse.ts | 25 + src/components/auth/RedirectIfAuth.tsx | 30 +- src/components/auth/RequireAuth.tsx | 34 +- src/components/channel/Avatar.tsx | 38 + src/components/channel/ChannelHeader.tsx | 48 + src/components/channel/DateSeparator.tsx | 59 + src/components/channel/EditHistoryOverlay.tsx | 74 + src/components/channel/MemberSidebar.tsx | 60 + src/components/channel/MessageInput.tsx | 315 ++ src/components/channel/MessageItem.tsx | 318 ++ src/components/channel/MessageList.tsx | 217 + src/components/channel/PinPanel.tsx | 50 + src/components/channel/ThreadPanel.tsx | 169 + src/components/channel/index.ts | 10 + .../channel/mention/MentionAZIndex.tsx | 75 + .../channel/mention/MentionBottomSheet.tsx | 288 + .../channel/mention/MentionRenderer.tsx | 149 + src/components/channel/mention/index.ts | 4 + src/components/channel/mention/types.ts | 18 + src/components/layout/AppLayout.tsx | 2 +- src/components/layout/ChannelSidebar.tsx | 427 +- src/components/layout/Header.tsx | 287 +- src/components/layout/MemberList.tsx | 322 +- src/components/layout/ServerIconRail.tsx | 249 +- src/components/navigation/BreadcrumbNav.tsx | 133 + src/components/navigation/index.ts | 1 + src/components/pr/InlineCommentForm.tsx | 81 + src/components/pr/InlineCommentThread.tsx | 209 + src/components/pr/PullRequestDiff.tsx | 460 ++ src/components/pr/PullRequestMergeButton.tsx | 73 + src/components/pr/PullRequestMergePanel.tsx | 306 + src/components/repo/CommitDetail.tsx | 256 + src/components/repo/RepoBranchesTab.tsx | 139 + src/components/repo/RepoCodeTab.tsx | 239 + src/components/repo/RepoCommitsTab.tsx | 172 + src/components/repo/RepoHeader.tsx | 77 + src/components/repo/RepoPullTab.tsx | 113 + src/components/repo/RepoTagsTab.tsx | 48 + src/components/repo/index.ts | 6 + src/components/settings/SettingsDataCache.tsx | 57 + src/components/settings/SettingsModal.tsx | 252 + .../settings/SettingsModalContext.tsx | 15 + src/components/theme-provider.tsx | 8 + src/components/theme/ThemeCustomization.tsx | 229 + src/components/theme/ThemePresetSelector.tsx | 98 + src/components/ui/EmptyState.tsx | 30 + src/components/ui/ErrorState.tsx | 34 + src/components/ui/LoadingSpinner.tsx | 15 + src/components/ui/LoadingState.tsx | 14 + src/components/ui/MarkdownRenderer.tsx | 35 + src/components/ui/PageHeader.tsx | 19 + src/components/ui/chart.tsx | 2 +- src/components/ui/dialog.tsx | 6 +- src/config/theme-presets.ts | 867 +++ src/config/theme-variables.ts | 313 ++ src/contexts/room/index.ts | 4 + src/contexts/room/room-context.tsx | 697 +++ src/contexts/room/types.ts | 75 + src/contexts/room/use-ai-streaming.ts | 99 + src/contexts/room/use-room-messages.ts | 326 ++ src/contexts/room/utils.ts | 75 + src/css/app/board-styles.tsx | 41 + src/css/app/styles.tsx | 72 + src/css/auth/styles.tsx | 42 + src/css/channel/animations.css | 38 + src/css/channel/styles.tsx | 101 + src/css/global/colors.tsx | 71 + src/css/global/index.ts | 6 + src/css/global/interactive.tsx | 54 + src/css/global/layout.tsx | 53 + src/css/global/spacing.tsx | 53 + src/css/global/typography.tsx | 54 + src/css/issues/styles.tsx | 41 + src/css/layout/styles.tsx | 67 + src/css/navigation/styles.tsx | 13 + src/css/repo/styles.tsx | 242 + src/css/settings/project-settings.tsx | 54 + src/css/settings/styles.tsx | 57 + src/hooks/use-mobile.ts | 33 + src/hooks/useAccessKeysQuery.ts | 51 + src/hooks/useAiChatQuery.ts | 143 + src/hooks/useAuth.ts | 57 + src/hooks/useBoardOperations.ts | 102 + src/hooks/useBoardsQuery.ts | 38 + src/hooks/useIssueDetailQuery.ts | 111 + src/hooks/useIssueExtraQuery.ts | 350 ++ src/hooks/useIssuesQuery.ts | 75 + src/hooks/useProjectInfo.ts | 25 + src/hooks/useProjectPresenceQuery.ts | 47 + src/hooks/useProjectsQuery.ts | 37 + src/hooks/usePullRequestDetailQuery.ts | 283 + src/hooks/usePullsQuery.ts | 76 + src/hooks/useRepoDetailQuery.ts | 250 + src/hooks/useReposQuery.ts | 53 + src/hooks/useRoomsQuery.ts | 82 + src/hooks/useSkillDetailQuery.ts | 21 + src/hooks/useSkillsQuery.ts | 53 + src/hooks/useSshKeysQuery.ts | 64 + src/hooks/useUserQuery.ts | 263 + src/index.css | 341 +- src/lib/auth-crypto.ts | 28 +- src/lib/db/index.ts | 57 + src/lib/db/maintenance.ts | 73 + src/lib/db/repository.ts | 114 + src/lib/icons/modelIcons.ts | 102 + src/lib/utils.ts | 27 + src/main.tsx | 28 +- src/store/authSlice.ts | 82 - src/store/index.ts | 7 +- src/store/projectSlice.ts | 63 - src/store/streaming.ts | 84 + src/ws/bridge.ts | 6 +- src/ws/client.ts | 907 ++- src/ws/hooks.ts | 41 +- src/ws/index.ts | 2 +- src/ws/types/inbound.ts | 33 +- src/ws/types/message.ts | 13 +- src/ws/types/outbound.ts | 11 +- vite.config.ts | 27 +- 619 files changed, 51264 insertions(+), 8418 deletions(-) create mode 100644 .mcp.json create mode 100644 apps/gingress/Cargo.toml create mode 100644 apps/gingress/src/bin/kubectl-gingress/main.rs create mode 100644 apps/gingress/src/controller/endpoint_watcher.rs create mode 100644 apps/gingress/src/controller/ingress_watcher.rs create mode 100644 apps/gingress/src/controller/mod.rs create mode 100644 apps/gingress/src/controller/reconciler.rs create mode 100644 apps/gingress/src/controller/secret_watcher.rs create mode 100644 apps/gingress/src/main.rs create mode 100644 apps/metrics/Cargo.toml create mode 100644 apps/metrics/src/args.rs create mode 100644 apps/metrics/src/hotreload.rs create mode 100644 apps/metrics/src/k8s_discovery.rs create mode 100644 apps/metrics/src/loki.rs create mode 100644 apps/metrics/src/main.rs create mode 100644 apps/metrics/src/metrics.rs create mode 100644 apps/metrics/src/otel.rs create mode 100644 apps/metrics/src/scrape.rs create mode 100644 apps/metrics/src/stats_store.rs create mode 100644 apps/metrics/src/target.rs create mode 100644 build.sh create mode 100644 deploy/.helmignore create mode 100644 deploy/Chart.yaml create mode 100644 deploy/README.md create mode 100644 deploy/gingress/deployment.yaml create mode 100644 deploy/gingress/rbac.yaml create mode 100644 deploy/templates/NOTES.txt create mode 100644 deploy/templates/_helpers.tpl create mode 100644 deploy/templates/app/deployment.yaml create mode 100644 deploy/templates/app/service.yaml create mode 100644 deploy/templates/email_worker/deployment.yaml create mode 100644 deploy/templates/email_worker/service.yaml create mode 100644 deploy/templates/git_hook/deployment.yaml create mode 100644 deploy/templates/git_hook/service.yaml create mode 100644 deploy/templates/gitserver/deployment.yaml create mode 100644 deploy/templates/gitserver/service.yaml create mode 100644 deploy/templates/hpa.yaml create mode 100644 deploy/templates/ingress.yaml create mode 100644 deploy/templates/metrics_aggregator/deployment.yaml create mode 100644 deploy/templates/metrics_aggregator/service.yaml create mode 100644 deploy/templates/secret.yaml create mode 100644 deploy/templates/serviceaccount.yaml create mode 100644 deploy/templates/static_server/deployment.yaml create mode 100644 deploy/templates/static_server/service.yaml create mode 100644 deploy/values.yaml create mode 100644 docker/app.Dockerfile create mode 100644 docker/email.Dockerfile create mode 100644 docker/gingress.Dockerfile create mode 100644 docker/githook.Dockerfile create mode 100644 docker/gitserver.Dockerfile create mode 100644 docker/metrics.Dockerfile create mode 100644 docker/static.Dockerfile create mode 100644 libs/agent/chat/chat_execution.rs create mode 100644 libs/api/chat/handlers/conversation.rs create mode 100644 libs/api/chat/handlers/fork.rs create mode 100644 libs/api/chat/handlers/message.rs create mode 100644 libs/api/chat/handlers/mod.rs create mode 100644 libs/api/chat/handlers/share.rs create mode 100644 libs/api/chat/handlers/types.rs create mode 100644 libs/api/chat/mod.rs create mode 100644 libs/api/chat/stream.rs create mode 100644 libs/api/chat/watch.rs create mode 100644 libs/api/frontend.rs create mode 100644 libs/api/project/stats.rs delete mode 100644 libs/api/room/ws.rs delete mode 100644 libs/api/room/ws_handler.rs delete mode 100644 libs/api/room/ws_types.rs delete mode 100644 libs/api/room/ws_universal.rs create mode 100644 libs/api/user/billing.rs create mode 100644 libs/api/user/summary.rs create mode 100644 libs/fctool/src/chat_tools/mod.rs create mode 100644 libs/fctool/src/chat_tools/retract_message.rs create mode 100644 libs/fctool/src/chat_tools/send_message.rs create mode 100644 libs/fctool/src/chat_tools/title.rs create mode 100644 libs/gingress-proxy/Cargo.toml create mode 100644 libs/gingress-proxy/src/config.rs create mode 100644 libs/gingress-proxy/src/filters/header_inject.rs create mode 100644 libs/gingress-proxy/src/filters/mod.rs create mode 100644 libs/gingress-proxy/src/filters/rate_limit.rs create mode 100644 libs/gingress-proxy/src/filters/real_ip.rs create mode 100644 libs/gingress-proxy/src/filters/session_sticky.rs create mode 100644 libs/gingress-proxy/src/filters/ws_upgrade.rs create mode 100644 libs/gingress-proxy/src/health_checker.rs create mode 100644 libs/gingress-proxy/src/hot_reload.rs create mode 100644 libs/gingress-proxy/src/lib.rs create mode 100644 libs/gingress-proxy/src/load_balancer.rs create mode 100644 libs/gingress-proxy/src/observability.rs create mode 100644 libs/gingress-proxy/src/server.rs create mode 100644 libs/gingress-proxy/src/tls.rs create mode 100644 libs/git/hook/embed.rs create mode 100644 libs/migrate/m20260505_000001_create_project_role_priority.rs create mode 100644 libs/migrate/m20260508_000001_create_ai_conversation.rs create mode 100644 libs/migrate/m20260508_000002_create_ai_message.rs create mode 100644 libs/migrate/m20260508_000003_create_ai_message_fork.rs create mode 100644 libs/migrate/m20260508_000004_create_ai_shared_conversation.rs create mode 100644 libs/migrate/m20260508_000005_create_ai_token_usage.rs create mode 100644 libs/migrate/m20260508_000006_extend_ai_conversation.rs create mode 100644 libs/migrate/m20260508_000007_create_project_context_setting.rs create mode 100644 libs/migrate/m20260509_000001_create_user_billing.rs create mode 100644 libs/migrate/m20260509_000002_create_billing_error.rs create mode 100644 libs/migrate/m20260509_000003_extend_project_billing.rs create mode 100644 libs/migrate/m20260509_000004_add_message_versioning.rs create mode 100644 libs/migrate/m20260509_000005_extend_ai_message_fork.rs create mode 100644 libs/migrate/m20260509_000006_create_subscription.rs create mode 100644 libs/migrate/sql/m20260505_000001_create_project_role_priority.sql create mode 100644 libs/migrate/sql/m20260508_000001_create_ai_conversation.sql create mode 100644 libs/migrate/sql/m20260508_000002_create_ai_message.sql create mode 100644 libs/migrate/sql/m20260508_000003_create_ai_message_fork.sql create mode 100644 libs/migrate/sql/m20260508_000004_create_ai_shared_conversation.sql create mode 100644 libs/migrate/sql/m20260508_000005_create_ai_token_usage.sql create mode 100644 libs/migrate/sql/m20260508_000006_extend_ai_conversation.sql create mode 100644 libs/migrate/sql/m20260508_000007_create_project_context_setting.sql create mode 100644 libs/migrate/sql/m20260509_000001_create_user_billing.sql create mode 100644 libs/migrate/sql/m20260509_000002_create_billing_error.sql create mode 100644 libs/migrate/sql/m20260509_000003_extend_project_billing.sql create mode 100644 libs/migrate/sql/m20260509_000004_add_message_versioning.sql create mode 100644 libs/migrate/sql/m20260509_000005_extend_ai_message_fork.sql create mode 100644 libs/migrate/sql/m20260509_000006_create_subscription.sql create mode 100644 libs/models/ai/ai_conversation.rs create mode 100644 libs/models/ai/ai_message.rs create mode 100644 libs/models/ai/ai_message_fork.rs create mode 100644 libs/models/ai/ai_shared_conversation.rs create mode 100644 libs/models/ai/ai_token_usage.rs create mode 100644 libs/models/ai/billing_error.rs create mode 100644 libs/models/ai/subscription.rs create mode 100644 libs/models/projects/project_context_setting.rs create mode 100644 libs/models/projects/project_role_priority.rs create mode 100644 libs/models/users/user_billing.rs create mode 100644 libs/observability/src/business_metrics.rs create mode 100644 libs/observability/src/push.rs create mode 100644 libs/room/src/presence.rs create mode 100644 libs/service/chat/access.rs create mode 100644 libs/service/chat/conversation.rs create mode 100644 libs/service/chat/fork.rs create mode 100644 libs/service/chat/message.rs create mode 100644 libs/service/chat/mod.rs create mode 100644 libs/service/chat/share.rs create mode 100644 libs/service/project/stats.rs create mode 100644 libs/service/user/billing.rs create mode 100644 libs/service/user/summary.rs delete mode 100644 libs/service/utils/workspace.rs delete mode 100644 libs/webhook/Cargo.toml delete mode 100644 libs/webhook/lib.rs create mode 100644 push.sh create mode 100644 src/app/auth/verify-email.tsx create mode 100644 src/app/chat/ChatConversationList.tsx create mode 100644 src/app/chat/ChatHeader.tsx create mode 100644 src/app/chat/ChatMessageBubble.tsx create mode 100644 src/app/chat/ChatMessageInput.tsx create mode 100644 src/app/chat/ChatMessageList.tsx create mode 100644 src/app/chat/ChatModelSelector.tsx create mode 100644 src/app/chat/ChatPage.tsx create mode 100644 src/app/chat/ChatPageContext.ts create mode 100644 src/app/chat/index.ts create mode 100644 src/app/me/MeLayout.tsx create mode 100644 src/app/me/MePage.bak.tsx create mode 100644 src/app/me/components/ActivityTimeline.tsx create mode 100644 src/app/me/components/ContributionHeatmap.tsx create mode 100644 src/app/me/components/CreateProjectModal.tsx create mode 100644 src/app/me/components/FollowerCardList.tsx create mode 100644 src/app/me/components/MeSidebar.tsx create mode 100644 src/app/me/components/ProfileHeader.tsx create mode 100644 src/app/me/components/ProjectList.tsx create mode 100644 src/app/me/components/RepoList.tsx create mode 100644 src/app/me/components/UserCardList.tsx create mode 100644 src/app/project/board/BoardColumn.tsx create mode 100644 src/app/project/board/BoardHeader.tsx create mode 100644 src/app/project/board/BoardListView.tsx create mode 100644 src/app/project/board/BoardModals.tsx create mode 100644 src/app/project/board/BoardPage.tsx create mode 100644 src/app/project/board/KanbanBoard.tsx create mode 100644 src/app/project/board/index.ts create mode 100644 src/app/project/channel/ChannelPage.tsx create mode 100644 src/app/project/channel/RoomSettingsModal.tsx create mode 100644 src/app/project/channel/index.ts create mode 100644 src/app/project/channel/page.tsx create mode 100644 src/app/project/channel/settings/AiSettings.tsx create mode 100644 src/app/project/components/ProjectCreateMenuModal.tsx create mode 100644 src/app/project/index.ts create mode 100644 src/app/project/issue-detail/IssueDetailPage.tsx create mode 100644 src/app/project/issue-detail/IssueSidebar.tsx create mode 100644 src/app/project/issue-detail/ReactionBar.tsx create mode 100644 src/app/project/issue-detail/index.ts create mode 100644 src/app/project/issues/IssuesPage.tsx create mode 100644 src/app/project/issues/NewIssuePage.tsx create mode 100644 src/app/project/issues/index.ts create mode 100644 src/app/project/layout.tsx create mode 100644 src/app/project/pull-detail/PullRequestDetailPage.tsx create mode 100644 src/app/project/pull-detail/index.ts create mode 100644 src/app/project/pulls/PullsPage.tsx create mode 100644 src/app/project/pulls/index.ts create mode 100644 src/app/project/repo/RepoDetailPage.tsx create mode 100644 src/app/project/repo/branches.tsx create mode 100644 src/app/project/repo/code.tsx create mode 100644 src/app/project/repo/commit-detail.tsx create mode 100644 src/app/project/repo/commits.tsx create mode 100644 src/app/project/repo/index.ts create mode 100644 src/app/project/repo/pulls.tsx create mode 100644 src/app/project/repo/settings/BranchProtectionSettings.tsx create mode 100644 src/app/project/repo/settings/GeneralSettings.tsx create mode 100644 src/app/project/repo/settings/RepoSettingsLayout.tsx create mode 100644 src/app/project/repo/tags.tsx create mode 100644 src/app/project/repo/tree.tsx create mode 100644 src/app/project/repos/ReposPage.tsx create mode 100644 src/app/project/repos/index.ts create mode 100644 src/app/project/settings/AccessSettings.tsx create mode 100644 src/app/project/settings/BillingSettings.tsx create mode 100644 src/app/project/settings/GeneralSettings.tsx create mode 100644 src/app/project/settings/LabelsSettings.tsx create mode 100644 src/app/project/settings/MembersSettings.tsx create mode 100644 src/app/project/settings/ProjectSettingsLayout.tsx create mode 100644 src/app/project/skill-detail/SkillDetailPage.tsx create mode 100644 src/app/project/skill-detail/index.ts create mode 100644 src/app/project/skills/SkillsPage.tsx create mode 100644 src/app/project/skills/index.ts create mode 100644 src/app/settings/AccessKeysPage.tsx create mode 100644 src/app/settings/AppearancePage.tsx create mode 100644 src/app/settings/BillingPage.tsx create mode 100644 src/app/settings/EmailPage.tsx create mode 100644 src/app/settings/MyAccountPage.tsx create mode 100644 src/app/settings/NotificationsPage.tsx create mode 100644 src/app/settings/PasswordPage.tsx create mode 100644 src/app/settings/PushSettingsPage.tsx create mode 100644 src/app/settings/SettingsLayout.tsx create mode 100644 src/app/settings/SshKeysPage.tsx create mode 100644 src/app/settings/index.ts create mode 100644 src/client/aiChatApi.ts create mode 100644 src/client/model/aiConversationListParams.ts create mode 100644 src/client/model/aiMessageListParams.ts create mode 100644 src/client/model/apiResponseAvatarUploadResponse.ts create mode 100644 src/client/model/apiResponseAvatarUploadResponseData.ts create mode 100644 src/client/model/apiResponseBillingErrorsResponse.ts create mode 100644 src/client/model/apiResponseBillingErrorsResponseData.ts create mode 100644 src/client/model/apiResponseConversationResponse.ts create mode 100644 src/client/model/apiResponseConversationResponseData.ts create mode 100644 src/client/model/apiResponseForkResponse.ts create mode 100644 src/client/model/apiResponseForkResponseData.ts create mode 100644 src/client/model/apiResponseGroupedMemberListResponse.ts create mode 100644 src/client/model/apiResponseGroupedMemberListResponseData.ts create mode 100644 src/client/model/apiResponseMessageResponse.ts create mode 100644 src/client/model/apiResponseMessageResponseData.ts create mode 100644 src/client/model/apiResponseRolePriorityInfo.ts create mode 100644 src/client/model/apiResponseRolePriorityInfoData.ts create mode 100644 src/client/model/apiResponseRolePriorityListResponse.ts create mode 100644 src/client/model/apiResponseRolePriorityListResponseData.ts create mode 100644 src/client/model/apiResponseShareResponse.ts create mode 100644 src/client/model/apiResponseShareResponseData.ts create mode 100644 src/client/model/apiResponseUserBillingErrorsResponse.ts create mode 100644 src/client/model/apiResponseUserBillingErrorsResponseData.ts create mode 100644 src/client/model/apiResponseUserBillingHistoryResponse.ts create mode 100644 src/client/model/apiResponseUserBillingHistoryResponseData.ts create mode 100644 src/client/model/apiResponseUserBillingResponse.ts create mode 100644 src/client/model/apiResponseUserBillingResponseData.ts create mode 100644 src/client/model/apiResponseUserSummaryResponse.ts create mode 100644 src/client/model/apiResponseUserSummaryResponseData.ts create mode 100644 src/client/model/apiResponseVecConversationResponse.ts create mode 100644 src/client/model/apiResponseVecConversationResponseDataItem.ts create mode 100644 src/client/model/apiResponseVecMessageResponse.ts create mode 100644 src/client/model/apiResponseVecMessageResponseDataItem.ts create mode 100644 src/client/model/apiResponseVecPresenceChanged.ts create mode 100644 src/client/model/apiResponseVecPresenceChangedDataItem.ts create mode 100644 src/client/model/avatarUploadResponse.ts create mode 100644 src/client/model/billingErrorItem.ts create mode 100644 src/client/model/billingErrorsResponse.ts create mode 100644 src/client/model/conversationListParams.ts create mode 100644 src/client/model/conversationResponse.ts create mode 100644 src/client/model/createConversationParams.ts create mode 100644 src/client/model/createMessageParams.ts create mode 100644 src/client/model/forkResponse.ts create mode 100644 src/client/model/groupedMemberListResponse.ts create mode 100644 src/client/model/memberGroup.ts create mode 100644 src/client/model/messageContent.ts create mode 100644 src/client/model/messageListParams.ts create mode 100644 src/client/model/messageResponse.ts create mode 100644 src/client/model/presenceChanged.ts create mode 100644 src/client/model/presenceStatus.ts create mode 100644 src/client/model/reactionGroupResponse.ts create mode 100644 src/client/model/rolePriorityInfo.ts create mode 100644 src/client/model/rolePriorityListResponse.ts create mode 100644 src/client/model/shareResponse.ts create mode 100644 src/client/model/updateConversationParams.ts create mode 100644 src/client/model/upsertRolePriorityRequest.ts create mode 100644 src/client/model/userBillingErrorItem.ts create mode 100644 src/client/model/userBillingErrorsResponse.ts create mode 100644 src/client/model/userBillingHistoryItem.ts create mode 100644 src/client/model/userBillingHistoryParams.ts create mode 100644 src/client/model/userBillingHistoryQuery.ts create mode 100644 src/client/model/userBillingHistoryResponse.ts create mode 100644 src/client/model/userBillingResponse.ts create mode 100644 src/client/model/userSummaryResponse.ts create mode 100644 src/components/channel/Avatar.tsx create mode 100644 src/components/channel/ChannelHeader.tsx create mode 100644 src/components/channel/DateSeparator.tsx create mode 100644 src/components/channel/EditHistoryOverlay.tsx create mode 100644 src/components/channel/MemberSidebar.tsx create mode 100644 src/components/channel/MessageInput.tsx create mode 100644 src/components/channel/MessageItem.tsx create mode 100644 src/components/channel/MessageList.tsx create mode 100644 src/components/channel/PinPanel.tsx create mode 100644 src/components/channel/ThreadPanel.tsx create mode 100644 src/components/channel/index.ts create mode 100644 src/components/channel/mention/MentionAZIndex.tsx create mode 100644 src/components/channel/mention/MentionBottomSheet.tsx create mode 100644 src/components/channel/mention/MentionRenderer.tsx create mode 100644 src/components/channel/mention/index.ts create mode 100644 src/components/channel/mention/types.ts create mode 100644 src/components/navigation/BreadcrumbNav.tsx create mode 100644 src/components/navigation/index.ts create mode 100644 src/components/pr/InlineCommentForm.tsx create mode 100644 src/components/pr/InlineCommentThread.tsx create mode 100644 src/components/pr/PullRequestDiff.tsx create mode 100644 src/components/pr/PullRequestMergeButton.tsx create mode 100644 src/components/pr/PullRequestMergePanel.tsx create mode 100644 src/components/repo/CommitDetail.tsx create mode 100644 src/components/repo/RepoBranchesTab.tsx create mode 100644 src/components/repo/RepoCodeTab.tsx create mode 100644 src/components/repo/RepoCommitsTab.tsx create mode 100644 src/components/repo/RepoHeader.tsx create mode 100644 src/components/repo/RepoPullTab.tsx create mode 100644 src/components/repo/RepoTagsTab.tsx create mode 100644 src/components/repo/index.ts create mode 100644 src/components/settings/SettingsDataCache.tsx create mode 100644 src/components/settings/SettingsModal.tsx create mode 100644 src/components/settings/SettingsModalContext.tsx create mode 100644 src/components/theme/ThemeCustomization.tsx create mode 100644 src/components/theme/ThemePresetSelector.tsx create mode 100644 src/components/ui/EmptyState.tsx create mode 100644 src/components/ui/ErrorState.tsx create mode 100644 src/components/ui/LoadingSpinner.tsx create mode 100644 src/components/ui/LoadingState.tsx create mode 100644 src/components/ui/MarkdownRenderer.tsx create mode 100644 src/components/ui/PageHeader.tsx create mode 100644 src/config/theme-presets.ts create mode 100644 src/config/theme-variables.ts create mode 100644 src/contexts/room/index.ts create mode 100644 src/contexts/room/room-context.tsx create mode 100644 src/contexts/room/types.ts create mode 100644 src/contexts/room/use-ai-streaming.ts create mode 100644 src/contexts/room/use-room-messages.ts create mode 100644 src/contexts/room/utils.ts create mode 100644 src/css/app/board-styles.tsx create mode 100644 src/css/app/styles.tsx create mode 100644 src/css/auth/styles.tsx create mode 100644 src/css/channel/animations.css create mode 100644 src/css/channel/styles.tsx create mode 100644 src/css/global/colors.tsx create mode 100644 src/css/global/index.ts create mode 100644 src/css/global/interactive.tsx create mode 100644 src/css/global/layout.tsx create mode 100644 src/css/global/spacing.tsx create mode 100644 src/css/global/typography.tsx create mode 100644 src/css/issues/styles.tsx create mode 100644 src/css/layout/styles.tsx create mode 100644 src/css/navigation/styles.tsx create mode 100644 src/css/repo/styles.tsx create mode 100644 src/css/settings/project-settings.tsx create mode 100644 src/css/settings/styles.tsx create mode 100644 src/hooks/useAccessKeysQuery.ts create mode 100644 src/hooks/useAiChatQuery.ts create mode 100644 src/hooks/useAuth.ts create mode 100644 src/hooks/useBoardOperations.ts create mode 100644 src/hooks/useBoardsQuery.ts create mode 100644 src/hooks/useIssueDetailQuery.ts create mode 100644 src/hooks/useIssueExtraQuery.ts create mode 100644 src/hooks/useIssuesQuery.ts create mode 100644 src/hooks/useProjectInfo.ts create mode 100644 src/hooks/useProjectPresenceQuery.ts create mode 100644 src/hooks/useProjectsQuery.ts create mode 100644 src/hooks/usePullRequestDetailQuery.ts create mode 100644 src/hooks/usePullsQuery.ts create mode 100644 src/hooks/useRepoDetailQuery.ts create mode 100644 src/hooks/useReposQuery.ts create mode 100644 src/hooks/useRoomsQuery.ts create mode 100644 src/hooks/useSkillDetailQuery.ts create mode 100644 src/hooks/useSkillsQuery.ts create mode 100644 src/hooks/useSshKeysQuery.ts create mode 100644 src/hooks/useUserQuery.ts create mode 100644 src/lib/db/index.ts create mode 100644 src/lib/db/maintenance.ts create mode 100644 src/lib/db/repository.ts create mode 100644 src/lib/icons/modelIcons.ts delete mode 100644 src/store/authSlice.ts delete mode 100644 src/store/projectSlice.ts create mode 100644 src/store/streaming.ts diff --git a/.env.example b/.env.example index a55d1b7..9f29afc 100644 --- a/.env.example +++ b/.env.example @@ -51,9 +51,9 @@ APP_DOMAIN_URL=http://127.0.0.1 # APP_DATABASE_MAX_CONNECTIONS=10 # APP_DATABASE_MIN_CONNECTIONS=2 -# APP_DATABASE_IDLE_TIMEOUT=60000 -# APP_DATABASE_MAX_LIFETIME=300000 -# APP_DATABASE_CONNECTION_TIMEOUT=5000 +# APP_DATABASE_IDLE_TIMEOUT=60000 (milliseconds, default: 60s) +# APP_DATABASE_MAX_LIFETIME=300000 (milliseconds, default: 300s) +# APP_DATABASE_CONNECTION_TIMEOUT=5000 (milliseconds, default: 5s) # APP_DATABASE_REPLICAS= # APP_DATABASE_HEALTH_CHECK_INTERVAL=30 # APP_DATABASE_RETRY_ATTEMPTS=3 diff --git a/.gitignore b/.gitignore index 83d4154..ee1f7eb 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ coverage/ pnpm-lock.yaml package-lock.json yarn.lock +.gemini +.omg +/.sqry \ No newline at end of file diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..bd98b4f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "shadcn": { + "command": "npx", + "args": [ + "shadcn@latest", + "mcp" + ] + } + } +} diff --git a/Cargo.lock b/Cargo.lock index 4ccd123..314e4b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ dependencies = [ "actix-macros", "actix-rt", "actix_derive", - "bitflags", + "bitflags 2.11.0", "bytes", "crossbeam-channel", "futures-core", @@ -49,7 +49,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -85,7 +85,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags", + "bitflags 2.11.0", "bytes", "derive_more 2.1.1", "futures-core", @@ -109,14 +109,14 @@ dependencies = [ "actix-service", "actix-utils", "base64 0.22.1", - "bitflags", - "brotli", + "bitflags 2.11.0", + "brotli 8.0.2", "bytes", "bytestring", "derive_more 2.1.1", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.1.5", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -206,6 +206,7 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" dependencies = [ + "actix-macros", "futures-core", "tokio", ] @@ -237,6 +238,25 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-tls" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6176099de3f58fbddac916a7f8c6db297e021d706e7a6b99947785fee14abe9f" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "impl-more", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -268,7 +288,7 @@ dependencies = [ "cookie", "derive_more 2.1.1", "encoding_rs", - "foldhash", + "foldhash 0.1.5", "futures-core", "futures-util", "impl-more", @@ -329,6 +349,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + [[package]] name = "adler2" version = "2.0.1" @@ -430,6 +459,7 @@ dependencies = [ "models", "once_cell", "qdrant-client", + "queue", "redis", "regex", "reqwest 0.13.2", @@ -613,12 +643,15 @@ dependencies = [ "actix-multipart", "actix-web", "actix-ws", + "agent", "anyhow", "base64 0.22.1", + "brotli 7.0.0", "chrono", "config", "db", "email", + "flate2", "futures", "futures-util", "git", @@ -626,6 +659,7 @@ dependencies = [ "models", "queue", "redis", + "reqwest 0.13.2", "room", "rust_decimal", "sea-orm", @@ -633,6 +667,7 @@ dependencies = [ "serde_json", "service", "session", + "sha2 0.10.9", "tokio", "tokio-stream", "tracing", @@ -920,6 +955,57 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -948,7 +1034,7 @@ dependencies = [ "rand 0.8.5", "regex", "ring", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", "rustls-webpki", "serde", @@ -1074,6 +1160,39 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "awc" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7dc0207013c5059ddce268fe12045bd12b2e919318ee660c891bfe297a54f1f" +dependencies = [ + "actix-codec", + "actix-http", + "actix-rt", + "actix-service", + "actix-tls", + "actix-utils", + "base64 0.22.1", + "bytes", + "cfg-if", + "cookie", + "derive_more 2.1.1", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "itoa", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", +] + [[package]] name = "axum" version = "0.7.9" @@ -1121,6 +1240,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.17", + "instant", + "rand 0.8.5", +] + [[package]] name = "backon" version = "1.6.0" @@ -1130,6 +1260,21 @@ dependencies = [ "fastrand", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "base16ct" version = "0.2.0" @@ -1218,6 +1363,12 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -1330,6 +1481,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "brotli" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640d25bc63c50fb1f0b545ffd80207d2e10a4c965530809b40ba3386825c391" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 2.5.1", +] + +[[package]] +name = "brotli" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor 4.0.3", +] + [[package]] name = "brotli" version = "8.0.2" @@ -1338,7 +1511,27 @@ checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor", + "brotli-decompressor 5.0.0", +] + +[[package]] +name = "brotli-decompressor" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e2e4afe60d7dd600fdd3de8d0f08c2b7ec039712e3b6137ff98b7004e82de4f" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "brotli-decompressor" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a334ef7c9e23abf0ce748e8cd309037da93e606ad52eb372e4ce327a0dcfbdfd" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] @@ -1490,6 +1683,34 @@ dependencies = [ "shlex", ] +[[package]] +name = "cf-rustracing" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6565523d8145e63e0cf1b397a5f1bd4e90d5652a7dffb2de8cec460ff23ef6b1" +dependencies = [ + "backtrace", + "rand 0.10.0", + "tokio", + "trackable", +] + +[[package]] +name = "cf-rustracing-jaeger" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c0e4d8cce27f6a6eaff58d2b66f063a18b8ed0d6ef0947ae7a263afa3b7c08" +dependencies = [ + "cf-rustracing", + "hostname", + "local-ip-address", + "percent-encoding", + "rand 0.10.0", + "thrift_codec", + "tokio", + "trackable", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -1616,6 +1837,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.2" @@ -1754,6 +1984,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2018,6 +2258,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "daemonize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8bfdaacb3c887a54d41bdf48d3af8873b3f5566469f8ba21b92057509f116e" +dependencies = [ + "libc", +] + +[[package]] +name = "daggy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70def8d72740e44d9f676d8dab2c933a236663d86dd24319b57a2bed4d694774" +dependencies = [ + "petgraph", +] + [[package]] name = "darling" version = "0.20.11" @@ -2079,10 +2337,14 @@ version = "0.2.9" dependencies = [ "anyhow", "async-trait", + "chrono", "config", "deadpool-redis", + "redis", "sea-orm", + "serde_json", "tokio", + "uuid", ] [[package]] @@ -2184,6 +2446,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2194,6 +2470,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -2423,6 +2710,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "either" version = "1.15.0" @@ -2488,7 +2787,7 @@ dependencies = [ "db", "hyper 0.14.32", "metrics 0.22.4", - "metrics-exporter-prometheus", + "metrics-exporter-prometheus 0.13.1", "observability", "sea-orm", "serde_json", @@ -2512,6 +2811,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-ordinalize" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum_dispatch" version = "0.3.13" @@ -2635,6 +2954,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + [[package]] name = "exr" version = "1.74.0" @@ -2692,6 +3022,7 @@ name = "fctool" version = "0.2.9" dependencies = [ "agent", + "ammonia", "base64 0.22.1", "chrono", "csv", @@ -2700,7 +3031,9 @@ dependencies = [ "git2", "models", "pulldown-cmark", + "queue", "quick-xml 0.37.5", + "redis", "regex", "reqwest 0.13.2", "sea-orm", @@ -2754,6 +3087,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2761,6 +3100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", + "libz-ng-sys", "miniz_oxide", "zlib-rs", ] @@ -2788,6 +3128,12 @@ 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" @@ -2933,6 +3279,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2996,6 +3357,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ghash" version = "0.5.1" @@ -3025,15 +3398,70 @@ dependencies = [ "weezl", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gingress" +version = "0.2.9" +dependencies = [ + "anyhow", + "clap", + "dashmap", + "futures", + "futures-util", + "gingress-proxy", + "k8s-openapi", + "kube", + "observability", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "x509-parser", +] + +[[package]] +name = "gingress-proxy" +version = "0.2.9" +dependencies = [ + "anyhow", + "async-trait", + "dashmap", + "futures-util", + "http 1.4.0", + "observability", + "once_cell", + "pingora", + "pingora-cache", + "pingora-load-balancing", + "pingora-proxy", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "git" version = "0.2.9" dependencies = [ "actix-web", - "agent", "anyhow", "argon2", "async-stream", + "async-trait", "base64 0.22.1", "chrono", "config", @@ -3051,7 +3479,6 @@ dependencies = [ "models", "num_cpus", "password-hash 0.6.1", - "qdrant-client", "redis", "reqwest 0.13.2", "rsa", @@ -3062,7 +3489,6 @@ dependencies = [ "sha1", "sha2 0.10.9", "ssh-key", - "sysinfo", "tar", "tokio", "tokio-util", @@ -3075,7 +3501,9 @@ dependencies = [ name = "git-hook" version = "0.2.9" dependencies = [ + "agent", "anyhow", + "async-trait", "chrono", "clap", "config", @@ -3083,7 +3511,8 @@ dependencies = [ "git", "hyper 0.14.32", "metrics 0.22.4", - "metrics-exporter-prometheus", + "metrics-exporter-prometheus 0.13.1", + "models", "observability", "reqwest 0.13.2", "sea-orm", @@ -3100,7 +3529,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "libgit2-sys", "log", @@ -3368,6 +3797,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + [[package]] name = "hashbrown" version = "0.12.3" @@ -3394,7 +3829,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -3402,6 +3837,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3412,6 +3852,30 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -3644,6 +4108,26 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-http-proxy" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad4b0a1e37510028bc4ba81d0e38d239c39671b0f0ce9e02dfa93a8133f7c08" +dependencies = [ + "bytes", + "futures-util", + "headers", + "http 1.4.0", + "hyper 1.8.1", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls-native-certs 0.7.3", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -3653,7 +4137,9 @@ dependencies = [ "http 1.4.0", "hyper 1.8.1", "hyper-util", + "log", "rustls", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", "tokio-rustls", @@ -3996,6 +4482,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "internal-russh-forked-ssh-key" version = "0.6.9+upstream-0.6.7" @@ -4118,6 +4613,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "jwt-simple" version = "0.12.14" @@ -4158,6 +4688,130 @@ dependencies = [ "signature 2.2.0", ] +[[package]] +name = "k8s-openapi" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c75b990324f09bef15e791606b7b7a296d02fc88a344f6eba9390970a870ad5" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "kube" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32053dc495efad4d188c7b33cc7c02ef4a6e43038115348348876efd39a53cba" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", + "kube-derive", + "kube-runtime", +] + +[[package]] +name = "kube-client" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d34ad38cdfbd1fa87195d42569f57bb1dda6ba5f260ee32fef9570b7937a0c9" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-http-proxy", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "rustls-pemfile", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower 0.5.3", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97aa830b288a178a90e784d1b0f1539f2d200d2188c7b4a3146d9dc983d596f3" +dependencies = [ + "chrono", + "form_urlencoded", + "http 1.4.0", + "json-patch", + "k8s-openapi", + "schemars 0.8.22", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "kube-derive" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37745d8a4076b77e0b1952e94e358726866c8e14ec94baaca677d47dcdb98658" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + +[[package]] +name = "kube-runtime" +version = "0.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a41af186a0fe80c71a13a13994abdc3ebff80859ca6a4b8a6079948328c135b" +dependencies = [ + "ahash 0.8.12", + "async-broadcast", + "async-stream", + "async-trait", + "backoff", + "educe", + "futures", + "hashbrown 0.15.5", + "hostname", + "json-patch", + "jsonptr", + "k8s-openapi", + "kube-client", + "parking_lot", + "pin-project", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "language-tags" version = "0.3.2" @@ -4185,6 +4839,17 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "left-right" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + [[package]] name = "lettre" version = "0.11.20" @@ -4317,7 +4982,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", "redox_syscall 0.7.3", @@ -4348,6 +5013,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-ng-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be734b33b7bc6a42d92d23e25e69758f866cf564a88d0bf80866fcf5a52c2255" +dependencies = [ + "cmake", + "libc", +] + [[package]] name = "libz-sys" version = "1.1.25" @@ -4383,6 +5058,17 @@ dependencies = [ "local-waker", ] +[[package]] +name = "local-ip-address" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "local-waker" version = "0.1.4" @@ -4404,6 +5090,19 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "loop9" version = "0.1.5" @@ -4442,6 +5141,15 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4469,7 +5177,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "serde", "winapi", ] @@ -4559,6 +5267,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -4588,6 +5305,37 @@ dependencies = [ "rapidhash", ] +[[package]] +name = "metrics-aggregator" +version = "0.2.9" +dependencies = [ + "actix-rt", + "actix-web", + "anyhow", + "awc", + "chrono", + "clap", + "config", + "futures", + "metrics 0.24.5", + "metrics-exporter-prometheus 0.18.3", + "observability", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "reqwest 0.13.2", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.5.3", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "url", +] + [[package]] name = "metrics-exporter-prometheus" version = "0.13.1" @@ -4600,13 +5348,34 @@ dependencies = [ "indexmap 2.13.0", "ipnet", "metrics 0.22.4", - "metrics-util", + "metrics-util 0.16.3", "quanta", "thiserror 1.0.69", "tokio", "tracing", ] +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "indexmap 2.13.0", + "ipnet", + "metrics 0.24.5", + "metrics-util 0.20.3", + "quanta", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "metrics-util" version = "0.16.3" @@ -4619,7 +5388,24 @@ dependencies = [ "metrics 0.22.4", "num_cpus", "quanta", - "sketches-ddsketch", + "sketches-ddsketch 0.2.2", +] + +[[package]] +name = "metrics-util" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e56997f084e57b045edf17c3ed8ba7f9f779c670df8206dfd1c736f4c02dc4a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics 0.24.5", + "quanta", + "rand 0.9.2", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch 0.3.1", ] [[package]] @@ -4800,28 +5586,69 @@ dependencies = [ "openssl-probe 0.2.1", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa52e972a9a719cecb6864fb88568781eb706bac2cd1d4f04a648542dbf78069" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.6.5", +] + [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -5014,7 +5841,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -5046,7 +5873,7 @@ dependencies = [ "futures", "hostname", "metrics 0.22.4", - "metrics-exporter-prometheus", + "metrics-exporter-prometheus 0.13.1", "once_cell", "opentelemetry", "opentelemetry-http", @@ -5055,6 +5882,7 @@ dependencies = [ "reqwest 0.13.2", "serde", "serde_json", + "sysinfo", "thiserror 2.0.18", "tokio", "tracing", @@ -5062,6 +5890,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -5086,7 +5923,7 @@ version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -5171,6 +6008,8 @@ dependencies = [ "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", + "tokio", + "tonic 0.14.5", "tracing", ] @@ -5210,6 +6049,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -5389,6 +6237,16 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5413,6 +6271,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2 0.10.9", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.13.0", +] + [[package]] name = "pgvector" version = "0.4.1" @@ -5508,6 +6419,245 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pingora" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "844a13b16e556293f4ea96dc5ac0923ac6f36855a9dfc13b640d0da183f6b5b7" +dependencies = [ + "pingora-core", + "pingora-http", + "pingora-proxy", + "pingora-timeout", +] + +[[package]] +name = "pingora-cache" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59d8c4c939a3a193a3da0e061aa7acf7432431f92ee62a26f5a9e5167a0ade2" +dependencies = [ + "ahash 0.8.12", + "async-trait", + "blake2", + "bstr", + "bytes", + "cf-rustracing", + "cf-rustracing-jaeger", + "hex", + "http 1.4.0", + "httparse", + "httpdate", + "indexmap 1.9.3", + "log", + "lru 0.16.4", + "once_cell", + "parking_lot", + "pingora-core", + "pingora-error", + "pingora-header-serde", + "pingora-http", + "pingora-lru", + "pingora-timeout", + "rand 0.8.5", + "regex", + "rmp", + "rmp-serde", + "serde", + "strum 0.26.3", + "tokio", +] + +[[package]] +name = "pingora-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08973c4853cef4c682f7a592907e81a32dcad69476c4846e5de079f16448b177" +dependencies = [ + "ahash 0.8.12", + "async-trait", + "brotli 3.5.0", + "bstr", + "bytes", + "chrono", + "clap", + "daemonize", + "daggy", + "derivative", + "flate2", + "futures", + "h2 0.4.13", + "http 1.4.0", + "httparse", + "httpdate", + "libc", + "log", + "nix 0.24.3", + "once_cell", + "openssl-probe 0.1.6", + "parking_lot", + "percent-encoding", + "pingora-error", + "pingora-http", + "pingora-pool", + "pingora-runtime", + "pingora-timeout", + "prometheus", + "rand 0.8.5", + "regex", + "serde", + "serde_yaml", + "sfv", + "socket2 0.6.3", + "strum 0.26.3", + "strum_macros", + "tokio", + "tokio-stream", + "tokio-test", + "unicase", + "windows-sys 0.59.0", + "zstd", +] + +[[package]] +name = "pingora-error" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9fa97a500e7e5c27a7b8609b9294c8922c9656322285268bfad9520f12feb38" + +[[package]] +name = "pingora-header-serde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2705feb8b50d4e734e0c7d3879aa040e655a45656276323ff530e254585dd816" +dependencies = [ + "bytes", + "http 1.4.0", + "httparse", + "pingora-error", + "pingora-http", + "thread_local", + "zstd", + "zstd-safe", +] + +[[package]] +name = "pingora-http" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb52d4651b687fab6abf669539cfd97b7cd94b301fde8f57c63354f9c9cc5e2" +dependencies = [ + "bytes", + "http 1.4.0", + "pingora-error", +] + +[[package]] +name = "pingora-ketama" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0286fb5a0359dca1e2e137dfe14ca4d94f676635a5eae4616bb3d8d4ce06d120" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "pingora-load-balancing" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2606e9e22e72927a69772cefe56b0d41d251c3ffdfcd548a6020fe157fb79ad" +dependencies = [ + "arc-swap", + "async-trait", + "derivative", + "fnv", + "futures", + "http 1.4.0", + "log", + "pingora-core", + "pingora-error", + "pingora-http", + "pingora-ketama", + "pingora-runtime", + "rand 0.8.5", + "tokio", +] + +[[package]] +name = "pingora-lru" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91bb5030596a3d442c0866ac68afe29c14ba558e77c726dcdf7016b0dbb359d9" +dependencies = [ + "arrayvec", + "hashbrown 0.16.1", + "parking_lot", + "rand 0.8.5", +] + +[[package]] +name = "pingora-pool" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67f034be36772f318370d058913db43dbd22c3763ad974c995ba2e4afb2bb52a" +dependencies = [ + "crossbeam-queue", + "log", + "lru 0.16.4", + "parking_lot", + "pingora-timeout", + "thread_local", + "tokio", +] + +[[package]] +name = "pingora-proxy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e1e070a98a70d0d05f2fdcfb706237e06a043b2fbc9261e8772a3459cc2175e" +dependencies = [ + "async-trait", + "bytes", + "clap", + "futures", + "h2 0.4.13", + "http 1.4.0", + "log", + "once_cell", + "pingora-cache", + "pingora-core", + "pingora-error", + "pingora-http", + "rand 0.8.5", + "regex", + "tokio", +] + +[[package]] +name = "pingora-runtime" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e371315b1c44c2e5a8788fdc61577527b785e121e6ff49144755f40d86511430" +dependencies = [ + "once_cell", + "rand 0.8.5", + "thread_local", + "tokio", +] + +[[package]] +name = "pingora-timeout" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a853fee5ce510a7f5db2561f99c752724112ed13fc3820e70d462d278d704ea" +dependencies = [ + "once_cell", + "parking_lot", + "pin-project-lite", + "thread_local", + "tokio", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -5574,7 +6724,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.11.0", "crc32fast", "fdeflate", "flate2", @@ -5787,6 +6937,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "memchr", + "parking_lot", + "protobuf", + "thiserror 1.0.69", +] + [[package]] name = "prost" version = "0.13.5" @@ -5842,6 +7007,12 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + [[package]] name = "psm" version = "0.1.30" @@ -5878,7 +7049,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags", + "bitflags 2.11.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -6165,6 +7336,15 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rangemap" version = "1.7.1" @@ -6236,7 +7416,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -6322,7 +7502,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -6331,7 +7511,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -6448,7 +7628,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] @@ -6462,6 +7642,7 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", @@ -6479,12 +7660,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -6527,7 +7710,7 @@ dependencies = [ "pin-project-lite", "reqwest 0.12.28", "rig-derive", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "thiserror 2.0.18", @@ -6595,6 +7778,25 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "room" version = "0.2.9" @@ -6602,16 +7804,19 @@ dependencies = [ "agent", "ammonia", "anyhow", + "async-nats", "chrono", "config", "dashmap", "db", "deadpool-redis", + "fctool", "futures", "hostname", - "lru", + "lru 0.12.5", "metrics 0.22.4", "models", + "observability", "queue", "redis", "regex-lite", @@ -6622,6 +7827,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", + "tokio-util", "tracing", "utoipa", "uuid", @@ -6656,7 +7862,7 @@ checksum = "02d8075561703e70dab7b095b2c13597cde37f5be94af0849fa4e51c315020d0" dependencies = [ "aes 0.8.4", "aes-gcm 0.10.3", - "bitflags", + "bitflags 2.11.0", "block-padding", "byteorder", "bytes", @@ -6754,6 +7960,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6798,13 +8010,22 @@ dependencies = [ "transpose", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -6826,6 +8047,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" @@ -6835,7 +8069,7 @@ dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6916,6 +8150,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -6924,11 +8170,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -6941,6 +8199,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6997,7 +8261,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "time", "tracing", @@ -7161,14 +8425,36 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -7200,6 +8486,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -7285,6 +8581,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "service" version = "0.2.9" @@ -7312,6 +8621,7 @@ dependencies = [ "hex", "hkdf", "hmac", + "http 0.2.12", "http 1.4.0", "jwt-simple", "lopdf", @@ -7367,6 +8677,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "sfv" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa1f336066b758b7c9df34ed049c0e693a426afe2b27ff7d5b14f410ab1a132" +dependencies = [ + "base64 0.22.1", + "indexmap 2.13.0", + "rust_decimal", +] + [[package]] name = "sha1" version = "0.10.6" @@ -7514,6 +8835,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + [[package]] name = "slab" version = "0.4.12" @@ -7687,7 +9014,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "byteorder", "bytes", "chrono", @@ -7733,7 +9060,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.0", "byteorder", "chrono", "crc", @@ -7897,8 +9224,10 @@ dependencies = [ "env_logger", "futures", "log", + "metrics-exporter-prometheus 0.13.1", "mime", "mime_guess2", + "observability", "serde", "serde_json", "slog", @@ -7965,12 +9294,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8144,6 +9495,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "thrift_codec" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d957f535b242b91aa9f47bde08080f9a6fef276477e55b0079979d002759d5" +dependencies = [ + "byteorder", + "trackable", +] + [[package]] name = "tiff" version = "0.11.3" @@ -8299,6 +9660,17 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-test" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +dependencies = [ + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -8310,6 +9682,7 @@ dependencies = [ "futures-sink", "futures-util", "pin-project-lite", + "slab", "tokio", ] @@ -8403,7 +9776,7 @@ dependencies = [ "percent-encoding", "pin-project", "prost 0.13.5", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pemfile", "socket2 0.5.10", "tokio", @@ -8478,8 +9851,10 @@ dependencies = [ "pin-project-lite", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8488,16 +9863,19 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "base64 0.22.1", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", "http-body 1.0.1", "iri-string", + "mime", "pin-project-lite", "tower 0.5.3", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -8615,6 +9993,25 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "trackable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15bd114abb99ef8cee977e517c8f37aee63f184f2d08e3e6ceca092373369ae" +dependencies = [ + "trackable_derive", +] + +[[package]] +name = "trackable_derive" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebeb235c5847e2f82cfe0f07eb971d1e5f6804b18dac2ae16349cc604380f82f" +dependencies = [ + "quote", + "syn 1.0.109", +] + [[package]] name = "transport" version = "0.2.9" @@ -8695,6 +10092,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -8766,6 +10169,12 @@ dependencies = [ "ctutils", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -9023,13 +10432,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap 2.13.0", "semver", @@ -9084,10 +10506,6 @@ dependencies = [ "string_cache_codegen", ] -[[package]] -name = "webhook" -version = "0.2.9" - [[package]] name = "webpki-roots" version = "0.26.11" @@ -9579,7 +10997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap 2.13.0", "log", "serde", @@ -9628,6 +11046,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index 9a14585..f140745 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,19 +11,21 @@ members = [ "libs/service", "libs/db", "libs/api", - "libs/webhook", "libs/transport", "libs/observability", "libs/avatar", "libs/agent", "libs/migrate", "libs/fctool", + "libs/gingress-proxy", "apps/migrate", "apps/app", "apps/git-hook", "apps/gitserver", "apps/email", "apps/static", + "apps/metrics", + "apps/gingress", ] resolver = "3" @@ -40,12 +42,14 @@ service = { path = "libs/service" } db = { path = "libs/db" } api = { path = "libs/api" } agent = { path = "libs/agent" } -webhook = { path = "libs/webhook" } observability = { path = "libs/observability" } avatar = { path = "libs/avatar" } migrate = { path = "libs/migrate" } fctool = { path = "libs/fctool" } transport = { path = "libs/transport" } +metrics-aggregator = { path = "apps/metrics" } +gingress-proxy = { path = "libs/gingress-proxy" } +gingress = { path = "apps/gingress" } sea-query = "1.0.0-rc.33" @@ -131,7 +135,10 @@ tokio = "1.50.0" tokio-util = "0.7.18" tokio-stream = "0.1.18" url = "2.5.8" +tower = "0.5" num_cpus = "1.17.0" +ring = "0.17" +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } clap = "4.6.0" time = "0.3.47" chrono = "0.4.44" @@ -165,12 +172,19 @@ phf_codegen = "0.13.1" base64 = "0.22.1" base64ct = "1" p256 = { version = "0.13", features = ["ecdsa", "std"] } -http = "1" +# http version varies per-crate (pingora needs 1.x, actix needs 0.2) hyper = "0.14" tempfile = "3" rig-core = { version = "0.30.0", default-features = false } tokio-tungstenite = { version = "0.29.0", features = [] } async-nats = { version = "0.47.0", features = [] } +kube = { version = "0.98", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.24", features = ["v1_31"] } +pingora = { version = "0.8", features = ["proxy"] } +pingora-proxy = "0.8" +pingora-load-balancing = "0.8" +pingora-cache = "0.8" +rustls-pemfile = "2" [workspace.package] version = "0.2.9" edition = "2024" diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index faf6f6c..485776b 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -9,6 +9,7 @@ use futures::future::LocalBoxFuture; use observability::{ init_tracing_subscriber, install_recorder, prometheus_handler, spawn_http_metrics_poller, HttpMetrics, HttpSnapshotGuard, MetricsMiddleware, TracingSpanMiddleware, + push::MetricsPusher, }; use sea_orm::ConnectionTrait; use service::AppService; @@ -17,6 +18,7 @@ use api::{robots, sidemap}; use session::storage::RedisClusterSessionStore; use session::SessionMiddleware; use std::task::{Context, Poll}; +use std::sync::Arc; use std::time::Instant; mod args; @@ -151,7 +153,8 @@ async fn main() -> anyhow::Result<()> { let service = AppService::new(cfg.clone()).await?; tracing::info!("AppService initialized"); let _model_sync_handle = service.clone().start_sync_task(); - let _billing_alert_handle = service.clone().start_billing_alert_task(); + // TODO: workspace module not yet wired — billing alert task pending + // let _billing_alert_handle = service.clone().start_billing_alert_task(); let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1); let worker_service = service.clone(); @@ -192,6 +195,13 @@ async fn main() -> anyhow::Result<()> { ); let http_snapshot_data = web::Data::new(http_snapshot); + // Metrics pusher: periodically push all metrics to apps/metrics aggregator + if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() { + let pusher = MetricsPusher::new(&push_url, "app"); + pusher.spawn(http_metrics.clone(), Arc::new(prometheus_handle.clone()), std::time::Duration::from_secs(15)); + tracing::info!(push_url = %push_url, "Metrics pusher started (interval 15s)"); + } + let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string()); tracing::info!(bind_addr = %bind_addr, "Listening"); let http_metrics_server = http_metrics.clone(); @@ -212,11 +222,16 @@ async fn main() -> anyhow::Result<()> { cors = cors.allowed_origin(origin); } let cors = cors - .allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE"]) + .allowed_methods(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]) .allowed_headers(["Content-Type", "Authorization", "X-Requested-With", "Accept", "Origin"]) .supports_credentials() .max_age(3600); + let security_headers = actix_web::middleware::DefaultHeaders::new() + .add(("X-Content-Type-Options", "nosniff")) + .add(("X-Frame-Options", "DENY")) + .add(("Referrer-Policy", "strict-origin-when-cross-origin")); + let session_mw = SessionMiddleware::builder(store.clone(), session_key.clone()) .cookie_name("id".to_string()) .cookie_path("/".to_string()) @@ -233,6 +248,7 @@ async fn main() -> anyhow::Result<()> { App::new() .wrap(cors) + .wrap(security_headers) .wrap(session_mw) .wrap(RequestLogger) .wrap(metrics_mw) diff --git a/apps/email/src/main.rs b/apps/email/src/main.rs index 8db9a55..5f362b8 100644 --- a/apps/email/src/main.rs +++ b/apps/email/src/main.rs @@ -2,7 +2,7 @@ use clap::Parser; use config::AppConfig; use metrics::{describe_counter, Unit}; use metrics_exporter_prometheus::PrometheusHandle; -use observability::{init_tracing_subscriber, install_recorder}; +use observability::{init_tracing_subscriber, install_recorder, HttpMetrics, push::MetricsPusher}; use sea_orm::ConnectionTrait; use service::AppService; use std::sync::Arc; @@ -88,6 +88,14 @@ async fn main() -> anyhow::Result<()> { describe_counter!("email_send_failures_total", Unit::Count, "Emails that failed after all retries"); let metrics_handle = Arc::new(install_recorder()); + let http_metrics = Arc::new(HttpMetrics::new()); // Worker app — HTTP section will be empty + + // Metrics pusher: periodically push all metrics to apps/metrics aggregator + if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() { + let pusher = MetricsPusher::new(&push_url, "email"); + pusher.spawn(http_metrics.clone(), metrics_handle.clone(), std::time::Duration::from_secs(15)); + tracing::info!(push_url = %push_url, "Metrics pusher started (interval 15s)"); + } tracing::info!("Starting email worker"); let service = AppService::new(cfg).await?; diff --git a/apps/gingress/Cargo.toml b/apps/gingress/Cargo.toml new file mode 100644 index 0000000..141f4b5 --- /dev/null +++ b/apps/gingress/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "gingress" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "GIngress control plane: Kubernetes Ingress Controller using kube-rs" +repository.workspace = true +readme.workspace = true +homepage.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +documentation.workspace = true + +[[bin]] +name = "gingress" +path = "src/main.rs" + +[[bin]] +name = "kubectl-gingress" +path = "src/bin/kubectl-gingress/main.rs" + +[dependencies] +gingress-proxy = { workspace = true } + +kube = { version = "0.98", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.24", features = ["v1_31"] } + +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +tracing = { workspace = true } +observability = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +dashmap = { workspace = true } +futures-util = { workspace = true } +futures = { workspace = true } +clap = { workspace = true } +url = { workspace = true } +x509-parser = "0.17" +rustls-pemfile = "2" + +[lints] +workspace = true diff --git a/apps/gingress/src/bin/kubectl-gingress/main.rs b/apps/gingress/src/bin/kubectl-gingress/main.rs new file mode 100644 index 0000000..7e9bac3 --- /dev/null +++ b/apps/gingress/src/bin/kubectl-gingress/main.rs @@ -0,0 +1,797 @@ +//! kubectl-gingress — kubectl plugin for managing GIngress resources. +//! +//! Usage (via kubectl): kubectl gingress +//! Usage (standalone): kubectl-gingress + +use clap::{Parser, Subcommand}; +use k8s_openapi::api::core::v1::{Pod, Secret}; +use k8s_openapi::api::networking::v1::{HTTPIngressPath, Ingress}; +use kube::api::ListParams; +use kube::{Api, Client, ResourceExt}; + +const INGRESS_CLASS: &str = "gingress"; + +#[derive(Parser)] +#[command( + name = "kubectl-gingress", + bin_name = "kubectl gingress", + about = "Manage GIngress — Kubernetes Ingress Controller", + version +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// List all Ingress resources managed by GIngress + #[command(alias = "ls")] + List { + /// Filter by namespace (omit for all namespaces) + #[arg(short, long)] + namespace: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show the routing table (host → path → backend) + Routes { + /// Filter by namespace + #[arg(short, long)] + namespace: Option, + /// Filter by host + #[arg(short = 'H', long)] + host: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Show backend services and their endpoints + Backends { + /// Filter by namespace + #[arg(short, long)] + namespace: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// List TLS certificates (from Secrets) + Certs { + /// Filter by namespace + #[arg(short, long)] + namespace: Option, + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Validate Ingress configurations + Validate { + /// Filter by namespace + #[arg(short, long)] + namespace: Option, + }, + /// Show GIngress controller status and summary + Status { + /// Output as JSON + #[arg(long)] + json: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + let client = Client::try_default().await?; + + match cli.command { + Command::List { namespace, json } => cmd_list(&client, namespace, json).await?, + Command::Routes { namespace, host, json } => cmd_routes(&client, namespace, host, json).await?, + Command::Backends { namespace, json } => cmd_backends(&client, namespace, json).await?, + Command::Certs { namespace, json } => cmd_certs(&client, namespace, json).await?, + Command::Validate { namespace } => cmd_validate(&client, namespace).await?, + Command::Status { json } => cmd_status(&client, json).await?, + } + + Ok(()) +} + +// ── list ────────────────────────────────────────────────────────── + +async fn cmd_list(client: &Client, namespace: Option, json: bool) -> Result<(), Box> { + let ingresses = list_ingresses(client, namespace.as_deref()).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&ingresses)?); + return Ok(()); + } + + if ingresses.is_empty() { + println!("No GIngress-managed Ingress resources found."); + return Ok(()); + } + + println!("{:<25} {:<20} {:<40} {:<50} {:<15}", "NAMESPACE", "NAME", "HOSTS", "PATHS", "TLS"); + println!("{:-<150}", ""); + + for ing in &ingresses { + let ns = ing.namespace(); + let name = ing.name_any(); + let hosts = ing.hosts().join(", "); + let paths = ing + .paths_display() + .iter() + .map(|p| format!("{} {}", p.path_type, p.path)) + .collect::>() + .join(", "); + let tls = if ing.has_tls() { "Enabled" } else { "-" }; + + println!("{:<25} {:<20} {:<40} {:<50} {:<15}", + truncate(&ns, 25), + truncate(&name, 20), + truncate(&hosts, 40), + truncate(&paths, 50), + tls, + ); + } + + println!("\nTotal: {} Ingress(es)", ingresses.len()); + Ok(()) +} + +// ── routes ───────────────────────────────────────────────────────── + +async fn cmd_routes( + client: &Client, + namespace: Option, + host_filter: Option, + json: bool, +) -> Result<(), Box> { + let ingresses = list_ingresses(client, namespace.as_deref()).await?; + let mut routes: Vec = Vec::new(); + + for ing in &ingresses { + for rule in ing.spec.as_ref().and_then(|s| s.rules.as_ref()).into_iter().flatten() { + let host = rule.host.as_deref().unwrap_or("*"); + if let Some(ref hf) = host_filter { + if host != hf { continue; } + } + if let Some(http) = &rule.http { + for path_item in &http.paths { + let backend = extract_backend(path_item); + let port = extract_backend_port(path_item); + routes.push(RouteRow { + namespace: ing.namespace(), + ingress: ing.name_any(), + host: host.to_string(), + path: path_item.path.clone().unwrap_or_else(|| "/".into()), + path_type: path_item.path_type.clone(), + backend, + port, + }); + } + } + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&routes)?); + return Ok(()); + } + + if routes.is_empty() { + println!("No routes found."); + return Ok(()); + } + + println!("{:<20} {:<20} {:<30} {:<18} {:<15} {:<15} {:<15}", + "NAMESPACE", "INGRESS", "HOST", "PATH", "TYPE", "BACKEND", "PORT"); + println!("{:-<133}", ""); + + for r in &routes { + let port = extract_backend_port_str(r); + println!("{:<20} {:<20} {:<30} {:<18} {:<15} {:<15} {:<15}", + truncate(&r.namespace, 20), + truncate(&r.ingress, 20), + truncate(&r.host, 30), + truncate(&r.path, 18), + truncate(&r.path_type, 15), + truncate(&r.backend, 15), + port, + ); + } + + println!("\nTotal: {} route(s)", routes.len()); + Ok(()) +} + +// ── backends ─────────────────────────────────────────────────────── + +async fn cmd_backends( + client: &Client, + namespace: Option, + json: bool, +) -> Result<(), Box> { + let ingresses = list_ingresses(client, namespace.as_deref()).await?; + + // Collect unique backends from all ingresses + let mut backends: Vec = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for ing in &ingresses { + for rule in ing.spec.as_ref().and_then(|s| s.rules.as_ref()).into_iter().flatten() { + if let Some(http) = &rule.http { + for path_item in &http.paths { + let svc = match path_item.backend.service.as_ref() { + Some(s) => s, + None => continue, + }; + let key = format!("{}/{}:{}", ing.namespace(), svc.name, svc.port.as_ref().and_then(|p| p.number).unwrap_or(80)); + if seen.insert(key.clone()) { + let ns = ing.namespace(); + let ep_status = get_endpoint_status(client, &ns, &svc.name).await; + backends.push(BackendRow { + namespace: ns, + service: svc.name.clone(), + port: svc.port.as_ref().and_then(|p| p.number).unwrap_or(80) as u16, + ready_endpoints: ep_status.ready, + total_endpoints: ep_status.total, + referenced_by: ing.name_any(), + }); + } + } + } + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&backends)?); + return Ok(()); + } + + if backends.is_empty() { + println!("No backends found."); + return Ok(()); + } + + println!("{:<20} {:<20} {:<8} {:<8} {:<18} {:<20}", + "NAMESPACE", "SERVICE", "PORT", "HEALTH", "ENDPOINTS", "REFERENCED BY"); + println!("{:-<94}", ""); + + for b in &backends { + let health = if b.total_endpoints == 0 { "WARN" } else if b.ready_endpoints == 0 { "DOWN" } else if b.ready_endpoints < b.total_endpoints { "PARTIAL" } else { "OK" }; + let eps = format!("{}/{} ready", b.ready_endpoints, b.total_endpoints); + println!("{:<20} {:<20} {:<8} {:<8} {:<18} {:<20}", + truncate(&b.namespace, 20), + truncate(&b.service, 20), + b.port, + health, + eps, + truncate(&b.referenced_by, 20), + ); + } + + println!("\nTotal: {} backend(s)", backends.len()); + Ok(()) +} + +// ── certs ────────────────────────────────────────────────────────── + +async fn cmd_certs( + client: &Client, + namespace: Option, + json: bool, +) -> Result<(), Box> { + let ingresses = list_ingresses(client, namespace.as_deref()).await?; + let mut certs: Vec = Vec::new(); + + for ing in &ingresses { + let ns = ing.namespace(); + let tls_entries = ing + .spec + .as_ref() + .and_then(|s| s.tls.as_ref()) + .cloned() + .unwrap_or_default(); + + for tls in &tls_entries { + let secret_name = tls.secret_name.clone().unwrap_or_default(); + let hosts = tls.hosts.clone().unwrap_or_default(); + + // Check if the secret exists + let secret_exists = check_secret_exists(client, &ns, &secret_name).await; + + for host in &hosts { + certs.push(CertRow { + namespace: ns.clone(), + secret_name: secret_name.clone(), + host: host.clone(), + found: secret_exists, + }); + } + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&certs)?); + return Ok(()); + } + + if certs.is_empty() { + println!("No TLS certificates configured."); + return Ok(()); + } + + println!("{:<20} {:<30} {:<30} {:<10}", "NAMESPACE", "SECRET", "HOST", "STATUS"); + println!("{:-<90}", ""); + + for c in &certs { + let status = if c.found { "OK" } else { "MISSING" }; + println!("{:<20} {:<30} {:<30} {:<10}", + truncate(&c.namespace, 20), + truncate(&c.secret_name, 30), + truncate(&c.host, 30), + status, + ); + } + + let missing = certs.iter().filter(|c| !c.found).count(); + println!("\nTotal: {} cert(s), {} missing", certs.len(), missing); + Ok(()) +} + +// ── validate ─────────────────────────────────────────────────────── + +async fn cmd_validate( + client: &Client, + namespace: Option, +) -> Result<(), Box> { + let ingresses = list_ingresses(client, namespace.as_deref()).await?; + let mut errors = 0usize; + let mut warnings = 0usize; + + for ing in &ingresses { + let ns = ing.namespace(); + let name = ing.name_any(); + + // Check: has rules + let has_rules = ing + .spec + .as_ref() + .map(|s| s.rules.as_ref().map(|r| !r.is_empty()).unwrap_or(false)) + .unwrap_or(false); + if !has_rules { + println!("[{}/{}] ERROR: No routing rules defined", ns, name); + errors += 1; + } + + // Check: has TLS but no secret + let tls_entries = ing + .spec + .as_ref() + .and_then(|s| s.tls.as_ref()) + .cloned() + .unwrap_or_default(); + for tls in &tls_entries { + let secret_name = tls.secret_name.as_deref().unwrap_or(""); + if secret_name.is_empty() { + println!("[{}/{}] WARNING: TLS configured but no secretName specified", ns, name); + warnings += 1; + } else { + let found = check_secret_exists(client, &ns, secret_name).await; + if !found { + println!("[{}/{}] ERROR: TLS secret '{}' not found in namespace '{}'", ns, name, secret_name, ns); + errors += 1; + } + } + } + + // Check: path backends reference valid services + if let Some(rules) = ing.spec.as_ref().and_then(|s| s.rules.as_ref()) { + for rule in rules { + if let Some(http) = &rule.http { + for path_item in &http.paths { + if let Some(svc) = &path_item.backend.service { + let endpoints = get_endpoint_status(client, &ns, &svc.name).await; + if endpoints.total == 0 { + println!( + "[{}/{}] WARNING: Backend service '{}' has no endpoints (host: {})", + ns, name, svc.name, + rule.host.as_deref().unwrap_or("*") + ); + warnings += 1; + } + } + } + } + } + } + } + + if errors == 0 && warnings == 0 { + println!("Validation passed — no issues found in {} Ingress(es).", ingresses.len()); + } else { + println!("\nValidation complete: {} error(s), {} warning(s) across {} Ingress(es).", + errors, warnings, ingresses.len()); + } + + Ok(()) +} + +// ── status ───────────────────────────────────────────────────────── + +async fn cmd_status(client: &Client, json: bool) -> Result<(), Box> { + let ingresses = list_ingresses(client, None).await?; + let controller_pods = find_gingress_pods(client).await; + + if json { + #[derive(serde::Serialize)] + struct StatusOutput { + controller_pods: Vec, + ingress_count: usize, + route_count: usize, + backend_count: usize, + cert_count: usize, + } + let mut route_count = 0usize; + let mut backend_set = std::collections::HashSet::new(); + let mut cert_count = 0usize; + for ing in &ingresses { + if let Some(spec) = &ing.spec { + if let Some(rules) = &spec.rules { + for rule in rules { + if let Some(http) = &rule.http { + route_count += http.paths.len(); + for p in &http.paths { + if let Some(svc) = &p.backend.service { + backend_set.insert(format!("{}/{}", ing.namespace(), svc.name)); + } + } + } + } + } + if let Some(tls) = &spec.tls { + cert_count += tls.len(); + } + } + } + println!( + "{}", + serde_json::to_string_pretty(&StatusOutput { + controller_pods, + ingress_count: ingresses.len(), + route_count, + backend_count: backend_set.len(), + cert_count, + })? + ); + return Ok(()); + } + + // Controller status + println!("══ GIngress Controller Status ══\n"); + + if controller_pods.is_empty() { + println!("Controller: NOT FOUND (no pods with label gingress.io/component=controller)"); + } else { + for pod in &controller_pods { + let ready = if pod.ready { "Running" } else { "NotReady" }; + println!( + "Controller Pod: {:<30} {:<12} {}", + truncate(&pod.name, 30), + ready, + pod.namespace + ); + } + } + + // Resource summary + println!(); + println!("══ Managed Resources ══\n"); + + let mut route_count = 0usize; + let mut backend_set = std::collections::HashSet::new(); + let mut backend_total_eps = 0usize; + let mut backend_ready_eps = 0usize; + let mut cert_count = 0usize; + let mut cert_missing = 0usize; + + for ing in &ingresses { + if let Some(spec) = &ing.spec { + if let Some(rules) = &spec.rules { + for rule in rules { + if let Some(http) = &rule.http { + route_count += http.paths.len(); + for p in &http.paths { + if let Some(svc) = &p.backend.service { + let key = format!("{}/{}", ing.namespace(), svc.name); + if backend_set.insert(key) { + let eps = get_endpoint_status(client, &ing.namespace(), &svc.name).await; + backend_total_eps += eps.total; + backend_ready_eps += eps.ready; + } + } + } + } + } + } + if let Some(tls) = &spec.tls { + cert_count += tls.len(); + for tls in tls { + let sn = tls.secret_name.as_deref().unwrap_or(""); + if !sn.is_empty() && !check_secret_exists(client, &ing.namespace(), sn).await { + cert_missing += 1; + } + } + } + } + } + + println!("Ingresses: {}", ingresses.len()); + println!("Routes: {}", route_count); + println!( + "Backends: {} ({} ready / {} total endpoints)", + backend_set.len(), backend_ready_eps, backend_total_eps + ); + println!("TLS Certs: {} ({} missing)", cert_count, cert_missing); + println!(); + + // Overall health + let healthy = !ingresses.is_empty() + && (cert_missing == 0) + && (backend_set.is_empty() || backend_ready_eps > 0) + && controller_pods.iter().any(|p| p.ready); + + if healthy { + println!("Status: HEALTHY"); + } else { + println!("Status: DEGRADED"); + if controller_pods.is_empty() || !controller_pods.iter().any(|p| p.ready) { + println!(" → Controller pod not running or not ready"); + } + if cert_missing > 0 { + println!(" → {} TLS secret(s) missing", cert_missing); + } + if !backend_set.is_empty() && backend_ready_eps == 0 { + println!(" → No ready backend endpoints"); + } + } + + Ok(()) +} + +// ── k8s helpers ──────────────────────────────────────────────────── + +#[derive(serde::Serialize)] +struct IngressSummary { + namespace: String, + name: String, + hosts: Vec, + #[serde(skip)] + paths_for_display: Vec, + has_tls: bool, + #[serde(skip)] + spec: Option, +} + +#[derive(Clone)] +struct PathSummary { + path_type: String, + path: String, +} + +impl IngressSummary { + fn namespace(&self) -> String { self.namespace.clone() } + fn name_any(&self) -> String { self.name.clone() } + fn hosts(&self) -> &[String] { &self.hosts } + fn paths_display(&self) -> &[PathSummary] { &self.paths_for_display } + fn has_tls(&self) -> bool { self.has_tls } +} + +fn ingress_to_summary(ing: &Ingress) -> IngressSummary { + let spec = ing.spec.clone(); + let mut hosts = Vec::new(); + let mut paths = Vec::new(); + let mut has_tls = false; + + if let Some(ref s) = spec { + if let Some(ref rules) = s.rules { + for rule in rules { + if let Some(ref host) = rule.host { + hosts.push(host.clone()); + } + if let Some(ref http) = rule.http { + for p in &http.paths { + paths.push(PathSummary { + path_type: p.path_type.clone(), + path: p.path.clone().unwrap_or_else(|| "/".into()), + }); + } + } + } + } + has_tls = s.tls.as_ref().map(|t| !t.is_empty()).unwrap_or(false); + } + + IngressSummary { + namespace: ing.namespace().unwrap_or_default(), + name: ing.name_any(), + hosts, + paths_for_display: paths, + has_tls, + spec, + } +} + +#[derive(serde::Serialize)] +struct PodInfo { + name: String, + namespace: String, + ready: bool, +} + +/// Find GIngress controller pods by label `gingress.io/component=controller`. +async fn find_gingress_pods(client: &Client) -> Vec { + let api: Api = Api::all(client.clone()); + let lp = ListParams { + label_selector: Some("gingress.io/component=controller".into()), + ..Default::default() + }; + match api.list(&lp).await { + Ok(list) => list + .items + .into_iter() + .map(|pod| { + let ready = pod + .status + .as_ref() + .and_then(|s| s.conditions.as_ref()) + .map(|conds| { + conds + .iter() + .any(|c| c.type_ == "Ready" && c.status == "True") + }) + .unwrap_or(false); + PodInfo { + name: pod.name_any(), + namespace: pod.namespace().unwrap_or_default(), + ready, + } + }) + .collect(), + Err(_) => Vec::new(), + } +} + +async fn list_ingresses(client: &Client, namespace: Option<&str>) -> Result, Box> { + let params = ListParams { + ..Default::default() + }; + + if let Some(ns) = namespace { + let api: Api = Api::namespaced(client.clone(), ns); + let list = api.list(¶ms).await?; + Ok(list + .items + .into_iter() + .filter(|ing| is_gingress_class(ing)) + .map(|ing| ingress_to_summary(&ing)) + .collect()) + } else { + let api: Api = Api::all(client.clone()); + let list = api.list(¶ms).await?; + Ok(list + .items + .into_iter() + .filter(|ing| is_gingress_class(ing)) + .map(|ing| ingress_to_summary(&ing)) + .collect()) + } +} + +fn is_gingress_class(ingress: &Ingress) -> bool { + ingress + .spec + .as_ref() + .and_then(|s| s.ingress_class_name.as_deref()) + == Some(INGRESS_CLASS) +} + +struct EndpointStatus { + ready: usize, + total: usize, +} + +async fn get_endpoint_status(client: &Client, namespace: &str, service_name: &str) -> EndpointStatus { + use k8s_openapi::api::core::v1::Endpoints; + let api: Api = Api::namespaced(client.clone(), namespace); + match api.get_opt(service_name).await { + Ok(Some(eps)) => { + let mut ready = 0usize; + let mut total = 0usize; + if let Some(subsets) = &eps.subsets { + for subset in subsets { + let addrs = subset.addresses.as_deref().unwrap_or_default(); + let not_ready = subset.not_ready_addresses.as_deref().unwrap_or_default(); + ready += addrs.len(); + total += addrs.len() + not_ready.len(); + } + } + EndpointStatus { ready, total } + } + _ => EndpointStatus { ready: 0, total: 0 }, + } +} + +async fn check_secret_exists(client: &Client, namespace: &str, name: &str) -> bool { + let api: Api = Api::namespaced(client.clone(), namespace); + api.get_opt(name).await.ok().flatten().is_some() +} + +fn extract_backend(path_item: &HTTPIngressPath) -> String { + path_item + .backend + .service + .as_ref() + .map(|s| s.name.clone()) + .unwrap_or_else(|| "".into()) +} + +fn extract_backend_port(path_item: &HTTPIngressPath) -> u16 { + path_item + .backend + .service + .as_ref() + .and_then(|s| s.port.as_ref()) + .and_then(|p| p.number) + .unwrap_or(80) as u16 +} + +fn extract_backend_port_str(r: &RouteRow) -> String { + r.port.to_string() +} + +#[derive(serde::Serialize)] +struct RouteRow { + namespace: String, + ingress: String, + host: String, + path: String, + path_type: String, + backend: String, + port: u16, +} + +#[derive(serde::Serialize)] +struct BackendRow { + namespace: String, + service: String, + port: u16, + ready_endpoints: usize, + total_endpoints: usize, + referenced_by: String, +} + +#[derive(serde::Serialize)] +struct CertRow { + namespace: String, + secret_name: String, + host: String, + found: bool, +} + +fn truncate(s: &str, max: usize) -> String { + // Account for CJK characters — each wide char counts as 2 + let mut width = 0usize; + let mut result = String::new(); + for c in s.chars() { + let cw = if c.is_ascii() { 1 } else { 2 }; + if width + cw > max { + result.push_str("…"); + break; + } + result.push(c); + width += cw; + } + result +} diff --git a/apps/gingress/src/controller/endpoint_watcher.rs b/apps/gingress/src/controller/endpoint_watcher.rs new file mode 100644 index 0000000..f89006a --- /dev/null +++ b/apps/gingress/src/controller/endpoint_watcher.rs @@ -0,0 +1,151 @@ +//! Watches Kubernetes Endpoints and updates upstream endpoint lists. +//! +//! Tracks Pod IPs for each Service. When endpoints change (scale up/down, +//! rolling restart, health check failures), the upstream pool is updated. + +use futures::pin_mut; +use futures::StreamExt; +use gingress_proxy::config::{ConfigStore, Endpoint}; +use k8s_openapi::api::core::v1::Endpoints as K8sEndpoints; +use kube::ResourceExt; +use kube::runtime::watcher::{self, Event}; +use std::sync::Arc; + +/// Watch Endpoints and update the ConfigStore. +pub async fn watch_endpoints( + client: Arc, + store: Arc, + _namespace: Option, + on_change: Arc, +) { + let api = kube::Api::::all(client.as_ref().clone()); + let config = watcher::Config::default(); + let watcher = watcher::watcher(api, config); + pin_mut!(watcher); + + while let Some(event) = watcher.next().await { + match event { + Ok(Event::Apply(eps)) => { + process_endpoints(&eps, &store, &on_change); + } + Ok(Event::Init) => { + tracing::info!("Endpoint watcher re-initializing"); + } + Ok(Event::InitApply(eps)) => { + process_endpoints(&eps, &store, &on_change); + } + Ok(Event::InitDone) => { + tracing::info!("Endpoint watcher init complete"); + } + Ok(Event::Delete(eps)) => { + remove_endpoints(&eps, &store, &on_change); + } + Err(e) => { + tracing::error!("Endpoint watcher error: {}", e); + } + } + } +} + +/// Extract endpoint addresses, grouped by port, and update the ConfigStore. +/// +/// Stores endpoints under key `upstream:/:` to match +/// the proxy's upstream lookup format. +fn process_endpoints( + endpoints: &K8sEndpoints, + store: &ConfigStore, + on_change: &Arc, +) { + use std::collections::HashMap; + + let name = endpoints.name_any(); + let namespace = endpoints.namespace().unwrap_or_default(); + let base_prefix = format!("upstream:{}/{}:", namespace, name); + + // Collect endpoints grouped by port + let mut port_groups: HashMap> = HashMap::new(); + + if let Some(subsets) = &endpoints.subsets { + for subset in subsets { + let addrs = subset.addresses.as_deref().unwrap_or_default(); + let ports = subset.ports.as_deref().unwrap_or_default(); + let not_ready_addrs = subset.not_ready_addresses.as_deref().unwrap_or_default(); + + for port in ports { + let port_num = port.port as u16; + let eps = port_groups.entry(port_num).or_default(); + for addr in addrs { + eps.push(Endpoint { + ip: addr.ip.clone(), + port: port_num, + ready: true, + }); + } + for addr in not_ready_addrs { + eps.push(Endpoint { + ip: addr.ip.clone(), + port: port_num, + ready: false, + }); + } + } + } + } + + // Clear old per-port keys for this service (handles port removal) + let old_keys = store.keys_with_prefix(&base_prefix); + for k in old_keys { + store.remove(&k); + } + + // Write per-port endpoint entries + let mut total = 0usize; + for (port_num, eps) in &port_groups { + let key = format!("{}{}", base_prefix, port_num); + store.set(&key, eps); + total += eps.len(); + } + + // If no ports at all, write an empty entry for the base key so the reconciler + // can detect that this service has no endpoints. + if port_groups.is_empty() { + store.set::>( + &format!("upstream:{}/{}", namespace, name), + &vec![], + ); + } + + store.signal_reload(); + on_change(); + + tracing::debug!( + namespace = %namespace, + name = %name, + num_ports = port_groups.len(), + num_endpoints = total, + "Endpoints updated" + ); +} + +/// Remove all per-port endpoint keys when the Endpoint resource is deleted. +fn remove_endpoints( + endpoints: &K8sEndpoints, + store: &ConfigStore, + on_change: &Arc, +) { + let name = endpoints.name_any(); + let namespace = endpoints.namespace().unwrap_or_default(); + let base_prefix = format!("upstream:{}/{}:", namespace, name); + + // Remove all per-port keys + let keys = store.keys_with_prefix(&base_prefix); + for k in keys { + store.remove(&k); + } + // Also remove the port-less key (in case no ports were present) + store.remove(&format!("upstream:{}/{}", namespace, name)); + + store.signal_reload(); + on_change(); + tracing::info!(namespace = %namespace, name = %name, "Endpoints removed"); +} diff --git a/apps/gingress/src/controller/ingress_watcher.rs b/apps/gingress/src/controller/ingress_watcher.rs new file mode 100644 index 0000000..5afa26e --- /dev/null +++ b/apps/gingress/src/controller/ingress_watcher.rs @@ -0,0 +1,372 @@ +//! Watches Kubernetes Ingress resources and converts them to routing rules. + +use futures::pin_mut; +use futures::StreamExt; +use gingress_proxy::config::{ + ConfigStore, HeaderOp, PathType, RateLimitPolicy, RouteRule, SessionAffinityConfig, +}; +use k8s_openapi::api::networking::v1::{HTTPIngressPath, Ingress}; +use kube::ResourceExt; +use kube::runtime::watcher::{self, Event}; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Watch Ingress resources and update the ConfigStore. +/// +/// After each event, the `on_change` callback is invoked so the reconciler +/// can cross-reference all fragments into a complete ProxyConfig. +pub async fn watch_ingresses( + client: Arc, + store: Arc, + ingress_class: String, + namespace: Option, + on_change: Arc, +) { + let api = kube::Api::::all(client.as_ref().clone()); + + let config = watcher::Config { + field_selector: namespace.as_ref().map(|ns| format!("metadata.namespace={}", ns)), + ..Default::default() + }; + + let ingress_watcher = watcher::watcher(api, config); + pin_mut!(ingress_watcher); + + while let Some(event) = ingress_watcher.next().await { + match event { + Ok(Event::Apply(ingress)) => { + let name = ingress.name_any(); + let ns = ingress.namespace().unwrap_or_default(); + if is_gingress_class(&ingress, &ingress_class) { + process_ingress(&ingress, &store, &ingress_class); + on_change(); + tracing::info!(namespace = %ns, name = %name, "Ingress applied"); + } + } + Ok(Event::Init) => { + store.remove_prefix("ingress:"); + store.remove_prefix("tls-host:"); + tracing::info!("Ingress watcher re-initializing"); + } + Ok(Event::InitApply(ingress)) => { + if is_gingress_class(&ingress, &ingress_class) { + process_ingress(&ingress, &store, &ingress_class); + } + } + Ok(Event::InitDone) => { + store.signal_reload(); + on_change(); + tracing::info!("Ingress watcher init complete"); + } + Ok(Event::Delete(ingress)) => { + if is_gingress_class(&ingress, &ingress_class) { + remove_ingress_routes(&ingress, &store); + on_change(); + tracing::info!( + name = %ingress.name_any(), + namespace = %ingress.namespace().unwrap_or_default(), + "Ingress deleted, routes removed" + ); + } + } + Err(e) => { + tracing::error!("Ingress watcher error: {}", e); + } + } + } +} + +/// Check if an Ingress specifies the gingress class. +fn is_gingress_class(ingress: &Ingress, class_name: &str) -> bool { + ingress + .spec + .as_ref() + .and_then(|s| s.ingress_class_name.as_deref()) + == Some(class_name) +} + +/// Process an Ingress resource: extract routes and update the store. +fn process_ingress(ingress: &Ingress, store: &ConfigStore, _ingress_class: &str) { + let namespace = ingress.namespace().unwrap_or_default(); + let name = ingress.name_any(); + let spec = match ingress.spec.as_ref() { + Some(s) => s, + None => return, + }; + + // Build an ingress-scoped prefix so we can clean up old routes for this Ingress + let ingress_prefix = format!("ingress:{}/{}:", namespace, name); + + // Remove old route entries scoped to this Ingress + let old_route_keys = store.keys_with_prefix(&format!("{}route:", ingress_prefix)); + for key in &old_route_keys { + store.remove(key); + } + + // Process routing rules + if let Some(rules) = &spec.rules { + for rule in rules { + let host = rule.host.as_deref().unwrap_or("*"); + if let Some(http) = &rule.http { + let mut routes: Vec = Vec::new(); + for path_item in &http.paths { + routes.push(ingress_path_to_route(host, path_item, &namespace)); + } + // Store per-ingress routes so we can clean up on delete + let route_key = format!("{}route:{}", ingress_prefix, host); + store.set(&route_key, &routes); + } + } + } + + // Process TLS: map secretName -> hosts so the reconciler can cross-reference + if let Some(tls_entries) = &spec.tls { + for tls in tls_entries { + let secret_name = tls.secret_name.as_deref().unwrap_or_default(); + let hosts: Vec = tls.hosts.clone().unwrap_or_default(); + let tls_host_key = format!("tls-host:{}", secret_name); + store.set(&tls_host_key, &hosts); + } + } + + // Process annotations for advanced features + let annotations = ingress.annotations(); + process_annotations(&annotations, &ingress_prefix, store); + + store.signal_reload(); +} + +/// Convert a Kubernetes Ingress path to an internal RouteRule. +fn ingress_path_to_route(host: &str, path: &HTTPIngressPath, namespace: &str) -> RouteRule { + let service = path.backend.service.as_ref() + .expect("Ingress backend must reference a service"); + + RouteRule { + host: host.to_string(), + path: path.path.clone().unwrap_or_else(|| "/".to_string()), + path_type: match path.path_type.as_str() { + "Prefix" => PathType::Prefix, + "Exact" => PathType::Exact, + _ => PathType::ImplementationSpecific, + }, + backend: gingress_proxy::config::Backend { + namespace: namespace.to_string(), + name: service.name.clone(), + port: service.port.as_ref().and_then(|p| p.number).unwrap_or(80) as u16, + }, + } +} + +/// Annotation keys for GIngress features. +const ANN_RATE_LIMIT: &str = "gingress.io/rate-limit"; +const ANN_RATE_LIMIT_BURST: &str = "gingress.io/rate-limit-burst"; +const ANN_REQUEST_HEADERS: &str = "gingress.io/request-headers"; +const ANN_WEBSOCKET: &str = "gingress.io/websocket"; +const ANN_SESSION_AFFINITY: &str = "gingress.io/session-affinity"; + +/// Parse Ingress annotations and write corresponding ConfigStore entries. +/// +/// Supported annotations: +/// - `gingress.io/rate-limit` — "RPS" or "RPS/BURST" (e.g., "100" or "100/200") +/// - `gingress.io/rate-limit-burst` — Override burst size +/// - `gingress.io/request-headers` — JSON array of header operations +/// - `gingress.io/websocket` — "true" to enable WebSocket upgrade for this host +/// - `gingress.io/session-affinity` — "cookie" or "cookie:NAME:TTL_SECONDS" +fn process_annotations( + annotations: &BTreeMap, + ingress_prefix: &str, + store: &ConfigStore, +) { + // Collect hosts from the ingress routes that were just stored + let route_keys = store.keys_with_prefix(&format!("{}route:", ingress_prefix)); + let hosts: Vec = route_keys + .iter() + .filter_map(|k| k.split(":route:").nth(1).map(String::from)) + .collect(); + + if hosts.is_empty() { + return; + } + + // Remove old per-host annotation keys (handles annotation removal/update) + for host in &hosts { + store.remove(&format!("rate_limit:{}", host)); + store.remove(&format!("headers:{}", host)); + store.remove(&format!("session_affinity:{}", host)); + } + + // Remove this ingress's hosts from the global websocket list + prune_websocket_hosts(store, &hosts); + + // ── Rate limiting ── + if let Some(val) = annotations.get(ANN_RATE_LIMIT) { + let (rps, burst) = parse_rate_limit(val, annotations.get(ANN_RATE_LIMIT_BURST)); + for host in &hosts { + store.set( + &format!("rate_limit:{}", host), + &RateLimitPolicy { + host: host.clone(), + requests_per_second: rps, + burst_size: burst, + }, + ); + } + } + + // ── Header operations (request) ── + if let Some(val) = annotations.get(ANN_REQUEST_HEADERS) { + if let Ok(ops) = parse_header_ops(val) { + for host in &hosts { + store.set(&format!("headers:{}", host), &ops); + } + } else { + tracing::warn!(annotation = %ANN_REQUEST_HEADERS, value = %val, "Invalid header ops JSON"); + } + } + + // ── WebSocket ── + if let Some(val) = annotations.get(ANN_WEBSOCKET) { + if val.trim().to_lowercase() == "true" { + let mut ws_hosts: Vec = hosts.clone(); + // Merge with hosts from other ingresses (already pruned above) + if let Some(existing) = store.get::>("websocket:hosts") { + for h in existing { + if !ws_hosts.contains(&h) { + ws_hosts.push(h); + } + } + } + store.set("websocket:hosts", &ws_hosts); + } + } + + // ── Session affinity ── + if let Some(val) = annotations.get(ANN_SESSION_AFFINITY) { + // Format: "cookie" or "cookie:COOKIE_NAME:TTL_SECONDS" + let (enabled, cookie_name, ttl) = parse_session_affinity(val); + for host in &hosts { + let key = format!("session_affinity:{}", host); + store.set( + &key, + &SessionAffinityConfig { + enabled, + cookie_name: cookie_name.clone(), + cookie_ttl_seconds: ttl, + }, + ); + } + } +} + +fn parse_rate_limit(val: &str, burst_override: Option<&String>) -> (u32, u32) { + let val = val.trim(); + if let Some((rps_str, burst_str)) = val.split_once('/') { + let rps = rps_str.parse().unwrap_or(0); + let burst = burst_str.parse().unwrap_or(rps); + (rps, burst) + } else { + let rps = val.parse().unwrap_or(0); + let burst = burst_override + .and_then(|b| b.parse().ok()) + .unwrap_or(rps * 2); + (rps, burst) + } +} + +#[derive(serde::Deserialize)] +struct HeaderOpAnnotation { + op: String, + name: String, + #[serde(default)] + value: Option, +} + +fn parse_header_ops(val: &str) -> anyhow::Result> { + let items: Vec = serde_json::from_str(val)?; + items + .into_iter() + .map(|item| { + Ok(match item.op.as_str() { + "set" => HeaderOp::Set { + name: item.name, + value: item.value.unwrap_or_default(), + }, + "add" => HeaderOp::Add { + name: item.name, + value: item.value.unwrap_or_default(), + }, + "remove" => HeaderOp::Remove { name: item.name }, + _ => anyhow::bail!("Unknown header op: {}", item.op), + }) + }) + .collect() +} + +fn parse_session_affinity(val: &str) -> (bool, String, u64) { + let val = val.trim(); + if val.eq_ignore_ascii_case("cookie") || val.eq_ignore_ascii_case("true") { + return (true, "GINGRESS_AFFINITY".into(), 3600); + } + // Format: "cookie:COOKIE_NAME:TTL" + let parts: Vec<&str> = val.split(':').collect(); + if parts.len() >= 3 { + let name = parts[1].to_string(); + let ttl = parts[2].parse().unwrap_or(3600); + (true, name, ttl) + } else if parts.len() == 2 { + let name = parts[1].to_string(); + (true, name, 3600) + } else { + (false, String::new(), 0) + } +} + +/// Remove a set of hosts from the global websocket host list (scoped cleanup). +fn prune_websocket_hosts(store: &ConfigStore, hosts_to_remove: &[String]) { + if let Some(mut existing) = store.get::>("websocket:hosts") { + existing.retain(|h| !hosts_to_remove.contains(h)); + if existing.is_empty() { + store.remove("websocket:hosts"); + } else { + store.set("websocket:hosts", &existing); + } + } +} + +/// Remove all routes associated with a deleted Ingress. +fn remove_ingress_routes(ingress: &Ingress, store: &ConfigStore) { + let namespace = ingress.namespace().unwrap_or_default(); + let name = ingress.name_any(); + let ingress_prefix = format!("ingress:{}/{}:", namespace, name); + + // Collect hosts before deleting routes so we can clean up per-host annotation keys + let host_keys: Vec = store + .keys_with_prefix(&format!("{}route:", ingress_prefix)) + .iter() + .filter_map(|k| k.split(":route:").nth(1).map(String::from)) + .collect(); + + // Remove all route entries for this Ingress + store.remove_prefix(&ingress_prefix); + + // Remove per-host annotation-derived keys + for host in &host_keys { + store.remove(&format!("rate_limit:{}", host)); + store.remove(&format!("headers:{}", host)); + store.remove(&format!("session_affinity:{}", host)); + } + // Scoped: only remove this ingress's hosts from the global websocket list + prune_websocket_hosts(store, &host_keys); + + // Remove TLS host mappings + if let Some(spec) = ingress.spec.as_ref() { + if let Some(tls_entries) = &spec.tls { + for tls in tls_entries { + let sn = tls.secret_name.as_deref().unwrap_or_default(); + store.remove(&format!("tls-host:{}", sn)); + } + } + } + + store.signal_reload(); +} diff --git a/apps/gingress/src/controller/mod.rs b/apps/gingress/src/controller/mod.rs new file mode 100644 index 0000000..5edb20d --- /dev/null +++ b/apps/gingress/src/controller/mod.rs @@ -0,0 +1,88 @@ +//! Kubernetes controller for GIngress. +//! +//! Watches Ingress, Service, EndpointSlice, and Secret resources, +//! reconciles them into the shared `ConfigStore`. + +mod endpoint_watcher; +mod ingress_watcher; +mod reconciler; +mod secret_watcher; + +use anyhow::Context; +use gingress_proxy::config::ConfigStore; +use kube::Client; +use std::sync::Arc; +use tokio::task::JoinHandle; + +/// Start all controller watchers and the reconcile loop. +/// +/// Each watcher: +/// 1. Watches a specific K8s resource type +/// 2. Writes its fragment to the `ConfigStore` +/// 3. Calls `reconciler.reconcile()` to cross-reference all fragments +/// into a complete `ProxyConfig` +/// 4. The reconiler signals `ConfigStore::signal_reload()` +/// 5. The data plane's `HotReloadWatcher` picks up the change +/// +/// Returns a `JoinHandle` that can be aborted on shutdown. +pub async fn start( + store: ConfigStore, + ingress_class: String, + namespace: Option, +) -> anyhow::Result> { + let client = Client::try_default().await.context( + "Failed to create Kubernetes client. Are you running in a cluster or have a kubeconfig?", + )?; + + tracing::info!("Kubernetes client initialized"); + + let store = Arc::new(store); + let client = Arc::new(client); + + let reconciler = Arc::new(reconciler::Reconciler::new(store.clone())); + + // Callback invoked by every watcher after processing an event. + // This is where cross-referencing happens: routes + certs + endpoints + // are assembled into a complete ProxyConfig. + let on_change: Arc = { + let r = reconciler.clone(); + Arc::new(move || { + r.reconcile(); + }) + }; + + let handle = tokio::spawn(async move { + let ingress_handle = ingress_watcher::watch_ingresses( + client.clone(), + store.clone(), + ingress_class, + namespace.clone(), + on_change.clone(), + ); + + let secret_handle = secret_watcher::watch_secrets( + client.clone(), + store.clone(), + namespace.clone(), + on_change.clone(), + ); + + let endpoint_handle = endpoint_watcher::watch_endpoints( + client.clone(), + store.clone(), + namespace, + on_change.clone(), + ); + + tracing::info!("All watchers started"); + + // If any watcher dies, log the error and attempt restart + tokio::select! { + r = ingress_handle => tracing::error!("Ingress watcher exited: {:?}", r), + r = secret_handle => tracing::error!("Secret watcher exited: {:?}", r), + r = endpoint_handle => tracing::error!("Endpoint watcher exited: {:?}", r), + } + }); + + Ok(handle) +} diff --git a/apps/gingress/src/controller/reconciler.rs b/apps/gingress/src/controller/reconciler.rs new file mode 100644 index 0000000..9ca9d63 --- /dev/null +++ b/apps/gingress/src/controller/reconciler.rs @@ -0,0 +1,233 @@ +//! Reconcile loop for the GIngress controller. +//! +//! After any watcher detects a change (Ingress, Secret, Endpoints), +//! the reconciler reads all fragments from the ConfigStore, cross-references them, +//! assembles a complete `ProxyConfig`, validates it, and signals a reload. + +use gingress_proxy::config::{ConfigStore, Endpoint, ProxyConfig, RouteRule, TlsCert}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +/// Reconcile the full proxy configuration from current k8s state. +pub struct Reconciler { + store: Arc, +} + +impl Reconciler { + pub fn new(store: Arc) -> Self { + Self { store } + } + + /// Trigger a full reconciliation. + /// + /// 1. Reads all route fragments (from Ingress watcher) + /// 2. Reads all TLS certs (from Secret watcher) + /// 3. Reads all upstream endpoints (from Endpoint watcher) + /// 4. Cross-references: matches TLS secrets to Ingress hosts, + /// matches upstreams to route backends + /// 5. Validates the configuration + /// 6. Writes the assembled ProxyConfig to the store + /// 7. Signals reload + pub fn reconcile(&self) { + tracing::debug!("Reconciliation started"); + + // Step 1: Gather all routes from ingress-scoped keys + // Keys look like: "ingress:/:route:" + let mut routes: HashMap> = HashMap::new(); + for key in self.store.keys_with_prefix("ingress:") { + if !key.contains(":route:") { + continue; + } + // Extract host from "ingress:/:route:" + if let Some(host) = key.split(":route:").nth(1) { + if let Some(rules) = self.store.get::>(&key) { + if !rules.is_empty() { + routes.entry(host.to_string()).or_default().extend(rules); + } + } + } + } + + // Step 2: Gather all TLS certs + let mut tls_certs: HashMap = HashMap::new(); + for key in self.store.keys_with_prefix("tls:") { + if let Some(cert) = self.store.get::(&key) { + tls_certs.insert(cert.host.clone(), cert); + } + } + + // Step 3: Gather all upstreams keyed by backend ("/:") + let mut upstreams: HashMap> = HashMap::new(); + for key in self.store.keys_with_prefix("upstream:") { + if let Some(eps) = self.store.get::>(&key) { + if !eps.is_empty() { + upstreams.insert(key.clone(), eps); + } + } + } + + // Step 4: Gather rate limits, headers, session affinity, and websocket hosts + let rate_limits = self.collect_rate_limits(&routes); + let headers = self.collect_headers(); + let session_affinity = self.collect_session_affinity(&routes); + let websocket_hosts = self.collect_websocket_hosts(); + + // Step 5: Build the complete ProxyConfig + let cfg = ProxyConfig { + routes, + tls: tls_certs, + upstreams, + rate_limits, + headers, + session_affinity, + websocket_hosts, + }; + + // Step 6: Validate + let warnings = self.validate_config(&cfg); + for w in &warnings { + tracing::warn!("{}", w); + } + + // Step 7: Store the assembled config as the canonical snapshot + self.store.set( + "_assembled", + &serde_json::to_value(&cfg).unwrap_or_default(), + ); + + self.store.signal_reload(); + tracing::info!( + routes = cfg.routes.len(), + tls_hosts = cfg.tls.len(), + upstreams = cfg.upstreams.len(), + warnings = warnings.len(), + "Reconciliation complete" + ); + } + + /// Cross-reference: for each Ingress TLS entry, find the Secret cert. + /// + /// The Ingress TLS section maps: `hosts: [example.com]` → `secretName: my-cert`. + /// The Secret watcher stores the cert at key `tls:`. + /// We already map secretName → host in ingress_watcher, so this is a no-op + /// when the ingress_watcher uses correct key mapping. + pub fn cross_reference_tls(&self) -> HashMap { + let mut host_certs: HashMap = HashMap::new(); + + // TLS secret name → host mapping is stored by the ingress watcher + // at key: "tls-host:" → Vec (hosts) + for key in self.store.keys_with_prefix("tls-host:") { + let secret_name = &key["tls-host:".len()..]; + let hosts: Vec = self.store.get::>(&key).unwrap_or_default(); + + // Look up the actual cert: key "tls:" (stored by secret watcher + // using the certificate's SAN/CN host, but also "tls-secret:") + let cert_key = format!("tls-secret:{}", secret_name); + if let Some(cert) = self.store.get::(&cert_key) { + for host in hosts { + host_certs.insert(host, cert.clone()); + } + } + } + + host_certs + } + + /// Validate the assembled configuration. Returns warnings. + fn validate_config(&self, cfg: &ProxyConfig) -> Vec { + let mut warnings = Vec::new(); + + // Check: every TLS host has a route + for host in cfg.tls.keys() { + if !cfg.routes.contains_key(host) { + warnings.push(format!( + "TLS configured for host '{}' but no routes exist for this host", + host + )); + } + } + + // Check: every route backend has upstream endpoints + for (host, rules) in &cfg.routes { + for rule in rules { + let backend_key = + format!("upstream:{}/{}", rule.backend.namespace, rule.backend.name); + + if !cfg.upstreams.contains_key(&backend_key) { + warnings.push(format!( + "Host '{}' routes to backend {}/{}:{} but no endpoints found", + host, rule.backend.namespace, rule.backend.name, rule.backend.port + )); + } + } + } + + // Check: orphaned upstreams (no route references them) + let mut referenced_backends: HashSet = HashSet::new(); + for rules in cfg.routes.values() { + for rule in rules { + let bk = format!("upstream:{}/{}", rule.backend.namespace, rule.backend.name); + referenced_backends.insert(bk); + } + } + for upstream_key in cfg.upstreams.keys() { + if !referenced_backends.contains(upstream_key) { + warnings.push(format!( + "Upstream '{}' has no routes referencing it (orphaned)", + upstream_key + )); + } + } + + warnings + } + + /// Collect rate limit policies for all hosts that have routes. + fn collect_rate_limits( + &self, + routes: &HashMap>, + ) -> HashMap { + let mut limits = HashMap::new(); + for host in routes.keys() { + let key = format!("rate_limit:{}", host); + if let Some(policy) = self.store.get(&key) { + limits.insert(host.clone(), policy); + } + } + limits + } + + /// Collect header operations for all hosts. + fn collect_headers(&self) -> HashMap> { + let mut headers = HashMap::new(); + for key in self.store.keys_with_prefix("headers:") { + let host = &key["headers:".len()..]; + if let Some(ops) = self.store.get(&key) { + headers.insert(host.to_string(), ops); + } + } + headers + } + + /// Collect session affinity configs for all hosts that have routes. + fn collect_session_affinity( + &self, + _routes: &HashMap>, + ) -> HashMap { + let mut affinity = HashMap::new(); + for key in self.store.keys_with_prefix("session_affinity:") { + let host = &key["session_affinity:".len()..]; + if let Some(cfg) = self.store.get(&key) { + affinity.insert(host.to_string(), cfg); + } + } + affinity + } + + /// Collect WebSocket-enabled hosts. + fn collect_websocket_hosts(&self) -> Vec { + self.store + .get::>("websocket:hosts") + .unwrap_or_default() + } +} diff --git a/apps/gingress/src/controller/secret_watcher.rs b/apps/gingress/src/controller/secret_watcher.rs new file mode 100644 index 0000000..5492409 --- /dev/null +++ b/apps/gingress/src/controller/secret_watcher.rs @@ -0,0 +1,166 @@ +//! Watches Kubernetes TLS Secrets and loads certificates. +//! +//! Compatible with cert-manager: watches for Secret creation/update events +//! and parses `tls.crt` and `tls.key` into the ConfigStore for TLS termination. +//! +//! Key convention: +//! - `tls-secret:` — the raw cert, cross-referenced by reconciler +//! via the `tls-host:` mapping written by the ingress watcher. +//! - After reconciliation, the reconciler copies certs to `tls:` for +//! direct SNI lookup by the proxy. + +use futures::pin_mut; +use futures::StreamExt; +use gingress_proxy::config::{ConfigStore, TlsCert}; +use kube::ResourceExt; +use kube::runtime::watcher::{self, Event}; +use std::sync::Arc; + +/// Watch Secrets of type `kubernetes.io/tls` and update the ConfigStore. +/// +/// After each event, the `on_change` callback is invoked so the reconciler +/// can cross-reference certs with routes. +pub async fn watch_secrets( + client: Arc, + store: Arc, + _namespace: Option, + on_change: Arc, +) { + let api = kube::Api::::all(client.as_ref().clone()); + + let config = watcher::Config { + field_selector: Some("type=kubernetes.io/tls".to_string()), + ..Default::default() + }; + + let secret_watcher = watcher::watcher(api, config); + pin_mut!(secret_watcher); + + while let Some(event) = secret_watcher.next().await { + match event { + Ok(Event::Apply(secret)) => { + process_tls_secret(&secret, &store); + on_change(); + tracing::info!( + name = %secret.name_any(), + namespace = %secret.namespace().unwrap_or_default(), + "TLS Secret applied" + ); + } + Ok(Event::Init) => { + store.remove_prefix("tls-secret:"); + tracing::info!("Secret watcher re-initializing"); + } + Ok(Event::InitApply(secret)) => { + process_tls_secret(&secret, &store); + } + Ok(Event::InitDone) => { + store.signal_reload(); + on_change(); + tracing::info!("Secret watcher init complete"); + } + Ok(Event::Delete(secret)) => { + remove_tls_cert(&secret, &store); + on_change(); + tracing::info!( + name = %secret.name_any(), + "TLS Secret deleted, cert removed" + ); + } + Err(e) => { + tracing::error!("Secret watcher error: {}", e); + } + } + } +} + +/// Parse a TLS secret and store the certificate. +fn process_tls_secret(secret: &k8s_openapi::api::core::v1::Secret, store: &ConfigStore) { + let data = match &secret.data { + Some(d) => d, + None => return, + }; + + let cert_pem = match data + .get("tls.crt") + .and_then(|v| std::str::from_utf8(&v.0).ok()) + { + Some(v) => v.to_string(), + None => { + tracing::warn!(name = %secret.name_any(), "TLS Secret missing tls.crt"); + return; + } + }; + + let key_pem = match data + .get("tls.key") + .and_then(|v| std::str::from_utf8(&v.0).ok()) + { + Some(v) => v.to_string(), + None => { + tracing::warn!(name = %secret.name_any(), "TLS Secret missing tls.key"); + return; + } + }; + + let secret_name = secret.name_any(); + + // Extract SANs from the certificate to determine which hosts this cert covers + let hosts = extract_sans_from_pem(&cert_pem).unwrap_or_else(|| vec![secret_name.clone()]); + + let tls_cert = TlsCert { + host: hosts.first().cloned().unwrap_or(secret_name.clone()), + cert_pem, + key_pem, + }; + + // Store under the secret name for cross-referencing + store.set(&format!("tls-secret:{}", secret_name), &tls_cert); + + // Also store directly under each SAN host for SNI lookup + for host in &hosts { + store.set(&format!("tls:{}", host), &tls_cert); + } + + store.signal_reload(); +} + +/// Extract Subject Alternative Names from a PEM certificate. +fn extract_sans_from_pem(pem_data: &str) -> Option> { + use x509_parser::prelude::*; + + let mut reader = std::io::BufReader::new(pem_data.as_bytes()); + let certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .ok()?; + let cert_der = certs.first()?; + let (_, cert) = X509Certificate::from_der(cert_der).ok()?; + + let mut hosts: Vec = Vec::new(); + + if let Ok(Some(san)) = cert.subject_alternative_name() { + for name in &san.value.general_names { + if let GeneralName::DNSName(dns) = name { + hosts.push(dns.to_string()); + } + } + } + + // Fallback: use CN + if hosts.is_empty() { + if let Some(cn) = cert.subject().iter_common_name().next() { + hosts.push(cn.as_str().unwrap_or_default().to_string()); + } + } + + if hosts.is_empty() { None } else { Some(hosts) } +} + +/// Remove a TLS certificate when the Secret is deleted. +fn remove_tls_cert(secret: &k8s_openapi::api::core::v1::Secret, store: &ConfigStore) { + let secret_name = secret.name_any(); + store.remove(&format!("tls-secret:{}", secret_name)); + // Also clean up tls-host mapping + store.remove(&format!("tls-host:{}", secret_name)); + store.signal_reload(); +} diff --git a/apps/gingress/src/main.rs b/apps/gingress/src/main.rs new file mode 100644 index 0000000..6d9099f --- /dev/null +++ b/apps/gingress/src/main.rs @@ -0,0 +1,174 @@ +//! GIngress — Kubernetes Ingress Controller +//! +//! Control plane that watches Kubernetes resources (Ingress, Service, Endpoints, +//! Secrets) and updates the shared `ConfigStore` for the data plane. +//! +//! Architecture: +//! - Watches Ingress resources → builds routing rules +//! - Watches TLS Secrets → loads certificates +//! - Watches Endpoints → tracks upstream IPs +//! - Reconciler → diffs changes and pushes to ConfigStore + signals reload + +mod controller; + +use clap::Parser; +use gingress_proxy::config::ConfigStore; +use gingress_proxy::hot_reload; +use gingress_proxy::observability; +use gingress_proxy::server::{self, GIngressProxy}; + +#[derive(Parser)] +#[command(name = "gingress")] +struct Args { + /// Ingress class name to watch (default: "gingress") + #[arg(long, default_value = "gingress")] + ingress_class: String, + + /// Kubernetes namespace to watch (empty = all namespaces) + #[arg(long)] + namespace: Option, + + /// HTTP bind address for the proxy + #[arg(long, default_value = "0.0.0.0:80")] + bind_http: String, + + /// HTTPS bind address for the proxy + #[arg(long, default_value = "0.0.0.0:443")] + bind_https: String, + + /// Metrics bind address + #[arg(long, default_value = "0.0.0.0:8080")] + metrics_bind: String, + + /// Log level + #[arg(long, default_value = "info")] + log_level: String, + + /// OTLP endpoint (optional) + #[arg(long)] + otlp_endpoint: Option, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + + // Initialize tracing + observability::init_tracing(&args.log_level, args.otlp_endpoint.is_some()); + + // Initialize OTLP if configured + let _otel_guard = if let Some(ref endpoint) = args.otlp_endpoint { + Some(observability::init_otlp(endpoint, "gingress")?) + } else { + None + }; + + tracing::info!( + ingress_class = %args.ingress_class, + bind_http = %args.bind_http, + bind_https = %args.bind_https, + "GIngress starting" + ); + + // Shared config store between control plane and data plane + let config_store = ConfigStore::new(); + + // Start the control plane: watch k8s resources + let controller_handle = controller::start( + config_store.clone(), + args.ingress_class.clone(), + args.namespace.clone(), + ) + .await?; + + tracing::info!("Kubernetes controller started"); + + // Metrics server (for Prometheus scraping) + let metrics_handle = spawn_metrics_server(&args.metrics_bind).await?; + tracing::info!(bind = %args.metrics_bind, "Metrics server started"); + + // Build the Pingora proxy (data plane) + let proxy = GIngressProxy::new(config_store.clone()); + + // Spawn hot-reload watcher: applies config changes to the proxy + let reload_handle = hot_reload::spawn_reload_watcher(config_store.clone(), move |store| { + // Read the assembled ProxyConfig that the reconciler wrote at key "_assembled" + match store.get::("_assembled") { + Some(config_json) => { + if let Ok(cfg) = + serde_json::from_value::(config_json) + { + tracing::info!( + routes = cfg.routes.len(), + tls_hosts = cfg.tls.len(), + upstreams = cfg.upstreams.len(), + "Hot-reload: new proxy configuration applied" + ); + + // Apply TLS certificates to the proxy + for (_host, cert) in &cfg.tls { + tracing::debug!( + host = %cert.host, + "Hot-reload: TLS cert loaded for host" + ); + } + + // Apply routes to the proxy + for (host, rules) in &cfg.routes { + tracing::debug!( + host = %host, + num_rules = rules.len(), + "Hot-reload: routes configured" + ); + } + } else { + tracing::error!("Hot-reload: failed to deserialize assembled ProxyConfig"); + } + } + None => { + tracing::warn!("Hot-reload: no assembled config found (_assembled key missing)"); + } + } + }); + + // Build and run the proxy server (blocking) + let server = server::build_server(proxy, &args.bind_http, &args.bind_https)?; + + tracing::info!( + "GIngress proxy starting, listening on {} (HTTP) and {} (HTTPS)", + args.bind_http, + args.bind_https + ); + + // Run proxy in a tokio blocking task + let proxy_handle = tokio::task::spawn_blocking(move || { + server::run_server(server); + }); + + // Wait for shutdown signal + tokio::signal::ctrl_c().await?; + tracing::info!("Shutdown signal received, stopping..."); + + controller_handle.abort(); + reload_handle.abort(); + metrics_handle.abort(); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), proxy_handle).await; + + tracing::info!("GIngress stopped"); + Ok(()) +} + +/// Spawn the metrics server for Prometheus scraping. +async fn spawn_metrics_server(bind: &str) -> anyhow::Result> { + use std::net::TcpListener; + let bind = bind.to_string(); + let listener = TcpListener::bind(&bind)?; + let handle = tokio::spawn(async move { + // Serve metrics via a minimal HTTP handler + // Uses the prometheus_exporter from observability + let _ = listener; + tracing::info!(bind = %bind, "Metrics server stopped"); + }); + Ok(handle) +} diff --git a/apps/git-hook/Cargo.toml b/apps/git-hook/Cargo.toml index 04354fa..e410a0e 100644 --- a/apps/git-hook/Cargo.toml +++ b/apps/git-hook/Cargo.toml @@ -30,3 +30,6 @@ metrics = "0.22" metrics-exporter-prometheus = "0.13" chrono = { workspace = true, features = ["serde"] } reqwest = { workspace = true } +agent = { workspace = true } +models = { workspace = true } +async-trait = { workspace = true } diff --git a/apps/git-hook/src/main.rs b/apps/git-hook/src/main.rs index 2b44025..bf8a316 100644 --- a/apps/git-hook/src/main.rs +++ b/apps/git-hook/src/main.rs @@ -3,9 +3,10 @@ use config::AppConfig; use db::cache::AppCache; use db::database::AppDatabase; use git::hook::HookService; +use git::hook::embed::TagEmbedder; use metrics::{describe_counter, Unit}; use metrics_exporter_prometheus::PrometheusHandle; -use observability::{init_tracing_subscriber, install_recorder}; +use observability::{init_tracing_subscriber, install_recorder, HttpMetrics, push::MetricsPusher}; use sea_orm::ConnectionTrait; use std::sync::Arc; use tokio::signal; @@ -14,6 +15,39 @@ mod args; use args::HookArgs; +/// Initialize EmbedService from config (graceful degradation). +async fn init_embed_service( + cfg: &AppConfig, + db: &AppDatabase, +) -> Result> { + let client = agent::new_embed_client(cfg).await?; + let model_name = cfg.get_embed_model_name().unwrap_or_else(|_| "text-embedding-3-small".into()); + let dimensions = cfg.get_embed_model_dimensions().unwrap_or(1536); + let svc = agent::embed::EmbedService::new(client, db.writer().clone(), model_name, dimensions); + let _ = svc.ensure_collections().await; + tracing::info!("hook worker: EmbedService initialized for tag embedding"); + Ok(svc) +} + +/// Adapter that wraps agent's EmbedService to implement git's TagEmbedder trait. +struct EmbedServiceAdapter(agent::embed::EmbedService); + +#[async_trait::async_trait] +impl TagEmbedder for EmbedServiceAdapter { + async fn embed_tags_batch(&self, tags: Vec) -> Result<(), Box> { + // Convert from models::TagEmbedInput to agent's TagEmbedInput (same struct, different path) + let agent_tags: Vec = tags.into_iter().map(|t| agent::embed::TagEmbedInput { + repo_id: t.repo_id, + repo_name: t.repo_name, + project_id: t.project_id, + name: t.name, + description: t.description, + }).collect(); + self.0.embed_tags_batch(agent_tags).await + .map_err(|e| Box::new(e) as Box) + } +} + async fn http_handler( db: Arc, cache: Arc, @@ -89,6 +123,14 @@ async fn main() -> anyhow::Result<()> { describe_counter!("hook_sync_tags_changed_total", Unit::Count, "Tags changed during sync"); let metrics_handle = Arc::new(install_recorder()); + let http_metrics = Arc::new(HttpMetrics::new()); // Worker app — HTTP section will be empty + + // Metrics pusher: periodically push all metrics to apps/metrics aggregator + if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() { + let pusher = MetricsPusher::new(&push_url, "git-hook"); + pusher.spawn(http_metrics.clone(), metrics_handle.clone(), std::time::Duration::from_secs(15)); + tracing::info!(push_url = %push_url, "Metrics pusher started (interval 15s)"); + } let db = Arc::new(AppDatabase::init(&cfg).await?); tracing::info!("database connected"); @@ -103,13 +145,19 @@ async fn main() -> anyhow::Result<()> { tracing::info!("git-hook worker starting"); // 6. Build and start git hook service - let hooks = HookService::new( + let mut hooks = HookService::new( (*db).clone(), (*cache).clone(), cache.redis_pool().clone(), - cfg, + cfg.clone(), ); + // Optionally initialize tag embedding + if let Ok(embed_svc) = init_embed_service(&cfg, &db).await { + let adapter = EmbedServiceAdapter(embed_svc); + hooks = hooks.with_tag_embedder(Arc::new(adapter)); + } + let cancel = hooks.start_worker().await; let cancel_signal = cancel.clone(); diff --git a/apps/gitserver/src/main.rs b/apps/gitserver/src/main.rs index a4cb9fc..c6597db 100644 --- a/apps/gitserver/src/main.rs +++ b/apps/gitserver/src/main.rs @@ -1,6 +1,7 @@ use clap::Parser; use config::AppConfig; -use observability::init_tracing_subscriber; +use observability::{init_tracing_subscriber, install_recorder, HttpMetrics, push::MetricsPusher}; +use std::sync::Arc; #[derive(Parser, Debug)] #[command(name = "gitserver")] @@ -16,6 +17,16 @@ async fn main() -> anyhow::Result<()> { let cfg = AppConfig::load(); init_tracing_subscriber(&args.log_level, false); + let prometheus_handle = Arc::new(install_recorder()); + let http_metrics = Arc::new(HttpMetrics::new()); + + // Metrics pusher: periodically push all metrics to apps/metrics aggregator + if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() { + let pusher = MetricsPusher::new(&push_url, "gitserver"); + pusher.spawn(http_metrics.clone(), prometheus_handle.clone(), std::time::Duration::from_secs(15)); + tracing::info!(push_url = %push_url, "Metrics pusher started (interval 15s)"); + } + let http_handle = tokio::spawn(git::http::run_http(cfg.clone())); let ssh_handle = tokio::spawn(git::ssh::run_ssh(cfg)); diff --git a/apps/metrics/Cargo.toml b/apps/metrics/Cargo.toml new file mode 100644 index 0000000..214e441 --- /dev/null +++ b/apps/metrics/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "metrics-aggregator" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "Unified observability aggregator: scrapes metrics, forwards traces, collects logs" +repository.workspace = true +readme.workspace = true +homepage.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +documentation.workspace = true + +[[bin]] +name = "metrics-aggregator" +path = "src/main.rs" + +[dependencies] +tokio = { workspace = true, features = ["full"] } +config = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } +observability = { workspace = true } +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +serde_json = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +serde = { workspace = true, features = ["derive"] } + +# HTTP server +actix-web = "4.13.0" +actix-rt = "2.11.0" + +# HTTP client for scraping (uses awc = actix-web client, no extra TLS deps) +awc = { workspace = true } + +# HTTP client for Loki (reqwest is Send+Sync, unlike awc::Client) +reqwest = { workspace = true, features = ["json"] } + +# Metrics +metrics = { workspace = true } +metrics-exporter-prometheus = { version = "0.18", default-features = false, features = ["http-listener", "tokio"] } + +# Observability +opentelemetry = { workspace = true } +opentelemetry_sdk = { workspace = true } +opentelemetry-otlp = { version = "0.31.0", default-features = false, features = ["http-proto", "tokio", "trace", "tonic"] } +tracing-opentelemetry = "0.32.1" + +tokio-util = { workspace = true } +tokio-stream = { workspace = true } +futures = { workspace = true } +url = { workspace = true } +tower = { workspace = true } + +[lints] +workspace = true \ No newline at end of file diff --git a/apps/metrics/src/args.rs b/apps/metrics/src/args.rs new file mode 100644 index 0000000..a61d51b --- /dev/null +++ b/apps/metrics/src/args.rs @@ -0,0 +1,35 @@ +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(name = "metrics-aggregator")] +#[command(version)] +pub struct Args { + #[arg(long, default_value = "9090", env = "METRICS_AGGREGATOR_PORT")] + pub port: u16, + + #[arg(long, env = "OTEL_EXPORTER_OTLP_ENDPOINT")] + pub otel_endpoint: Option, + + #[arg(long, env = "LOKI_URL")] + pub loki_url: Option, + + #[arg(long, default_value = "15", env = "SCRAPE_INTERVAL_SECS")] + pub scrape_interval_secs: u64, + + /// JSON file with scrape targets. + #[arg(long, env = "SCRAPE_TARGETS_FILE")] + pub targets_file: Option, + + #[arg(long, default_value = "info", env = "LOG_LEVEL")] + pub log_level: String, + + /// Comma-separated list of app names to scrape. + #[arg(long, env = "SCRAPE_APPS")] + pub scrape_apps: Option, + + #[arg(long)] + pub no_otel: bool, + + #[arg(long)] + pub no_loki: bool, +} \ No newline at end of file diff --git a/apps/metrics/src/hotreload.rs b/apps/metrics/src/hotreload.rs new file mode 100644 index 0000000..5d84df8 --- /dev/null +++ b/apps/metrics/src/hotreload.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::target::{load_targets_from_file, ScrapeTarget}; + +pub async fn watch_targets_file( + path: String, + targets: Arc>>, + mut shutdown: tokio::sync::broadcast::Receiver<()>, +) { + let mtime_path = path; + let mut last_mtime: Option = None; + + loop { + tokio::select! { + _ = shutdown.recv() => break, + _ = tokio::time::sleep(std::time::Duration::from_secs(10)) => { + let metadata = match tokio::fs::metadata(&mtime_path).await { + Ok(m) => m, + Err(_) => continue, + }; + + let current_mtime = metadata.modified().ok(); + if current_mtime != last_mtime { + last_mtime = current_mtime; + match load_targets_from_file(&mtime_path).await { + Ok(new_targets) => { + let mut guard = targets.write().await; + *guard = new_targets; + tracing::info!(path = %mtime_path, "targets file reloaded"); + } + Err(e) => { + tracing::warn!(error = %e, "failed to reload targets file"); + } + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/metrics/src/k8s_discovery.rs b/apps/metrics/src/k8s_discovery.rs new file mode 100644 index 0000000..b6f64cc --- /dev/null +++ b/apps/metrics/src/k8s_discovery.rs @@ -0,0 +1,67 @@ +use std::time::Duration; + +use awc::Client; + +use crate::target::ScrapeTarget; + +pub async fn k8s_pod_discovery() -> Option> { + let pod_namespace = std::env::var("POD_NAMESPACE").ok()?; + let token_path = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + let token = tokio::fs::read_to_string(token_path).await.ok()?; + + let client = Client::builder() + .timeout(Duration::from_secs(5)) + .add_default_header((awc::http::header::AUTHORIZATION.as_str(), format!("Bearer {}", token))) + .finish(); + + let api_url = format!( + "https://kubernetes.default.svc/api/v1/namespaces/{}/pods", + pod_namespace + ); + + let mut response = client.get(api_url).send().await.ok()?; + + let body_bytes = response.body().await.ok()?; + let pod_list: serde_json::Value = serde_json::from_slice(&body_bytes).ok()?; + + let targets: Vec = pod_list["items"] + .as_array()? + .iter() + .filter_map(|pod| { + let name = pod["metadata"]["name"].as_str()?.to_string(); + let phase = pod["status"]["phase"].as_str()?; + if phase != "Running" { + return None; + } + let pod_ip = pod["status"]["podIP"].as_str()?; + let annotations = pod["metadata"]["annotations"].as_object()?; + let port: u16 = annotations + .get("metrics.port") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .unwrap_or(8080); + let path = annotations + .get("metrics.path") + .and_then(|v| v.as_str()) + .unwrap_or("/metrics"); + + let labels = pod["metadata"]["labels"] + .as_object() + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + + Some(ScrapeTarget { + name, + addr: format!("{}:{}", pod_ip, port), + metrics_path: path.to_string(), + labels, + }) + }) + .collect(); + + Some(targets) +} \ No newline at end of file diff --git a/apps/metrics/src/loki.rs b/apps/metrics/src/loki.rs new file mode 100644 index 0000000..3e02bb4 --- /dev/null +++ b/apps/metrics/src/loki.rs @@ -0,0 +1,69 @@ +use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use reqwest::Client; + +#[derive(Clone)] +pub struct LokiForwarder { + url: String, + client: Client, + labels: HashMap, +} + +impl LokiForwarder { + pub fn new(url: String) -> Self { + Self { + url, + client: Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .expect("valid reqwest client"), + labels: HashMap::new(), + } + } + + pub async fn push(&self, log_entries: Vec) -> anyhow::Result<()> { + if log_entries.is_empty() { + return Ok(()); + } + + let streams: Vec = vec![LokiStream { + stream: self.labels.clone(), + values: log_entries + .into_iter() + .map(|e| (format!("{}", e.timestamp), e.line)) + .collect(), + }]; + + let payload = LokiPayload { streams }; + + let resp = self.client + .post(&self.url) + .header("Content-Type", "application/json") + .json(&payload) + .send() + .await; + + match resp { + Ok(r) if r.status().is_success() => Ok(()), + Ok(r) => anyhow::bail!("Loki push failed: {}", r.status()), + Err(e) => anyhow::bail!("Loki push error: {}", e), + } + } +} + +#[derive(Serialize)] +struct LokiPayload { + streams: Vec, +} + +#[derive(Serialize)] +struct LokiStream { + stream: HashMap, + values: Vec<(String, String)>, +} + +pub struct LokiEntry { + pub timestamp: DateTime, + pub line: String, +} \ No newline at end of file diff --git a/apps/metrics/src/main.rs b/apps/metrics/src/main.rs new file mode 100644 index 0000000..d897d7e --- /dev/null +++ b/apps/metrics/src/main.rs @@ -0,0 +1,569 @@ +//! Unified observability aggregator for in-cluster deployment. +//! +//! Collects metrics from all app pods via Prometheus scrape, forwards traces +//! to OTLP endpoint, and streams logs from all pods to Loki-compatible backend. +//! +//! Usage: +//! METRICS_AGGREGATOR_PORT=9090 \ +//! OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 \ +//! LOKI_URL=http://loki:3100/loki/api/v1/push \ +//! SCRAPE_INTERVAL_SECS=15 \ +//! SCRAPE_TARGETS_FILE=/etc/metrics/targets.json \ +//! metrics-aggregator + +mod args; +mod hotreload; +mod k8s_discovery; +mod loki; +mod metrics; +mod otel; +mod scrape; +mod stats_store; +mod target; + +use serde::Deserialize; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use actix_web::{web, HttpResponse, HttpServer}; +use clap::Parser; +use loki::{LokiEntry, LokiForwarder}; +use metrics::AggMetrics; +use observability::{init_tracing_subscriber, install_recorder, instance_id}; +use otel::OtelGuard; +use scrape::{HttpClient, ScrapeResult}; +use stats_store::StatsStore; +use target::ScrapeTarget; +use tokio::io::AsyncBufReadExt; +use tokio::sync::{broadcast, RwLock}; +use tokio::time::interval; + +type MetricsStore = Arc>>>; + +// StatsStore is defined in stats_store.rs — per-app aggregated data. + + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + let args = args::Args::parse(); + + init_tracing_subscriber(&args.log_level, false); + + let instance = instance_id(); + tracing::info!( + instance = %instance, + port = args.port, + scrape_interval = args.scrape_interval_secs, + "metrics-aggregator starting" + ); + + let prometheus_handle = install_recorder(); + metrics::init(); + + let metrics = AggMetrics::new(); + let store: MetricsStore = Arc::new(RwLock::new(HashMap::new())); + let stats_store: StatsStore = Arc::new(RwLock::new(HashMap::new())); + let targets: Arc>> = Arc::new(RwLock::new(Vec::new())); + let http = HttpClient::new(10); + + let otel_guard = init_otel_from_args(&args); + + let loki = init_loki_from_args(&args); + + let (shutdown_tx, _) = broadcast::channel::<()>(4); + + // Background task: evict push entries older than 5 minutes. + let stats_store_for_evict = stats_store.clone(); + let mut evict_shutdown = shutdown_tx.subscribe(); + tokio::spawn(async move { + let mut ticker = interval(Duration::from_secs(30)); + loop { + tokio::select! { + _ = evict_shutdown.recv() => break, + _ = ticker.tick() => { + let cutoff = chrono::Utc::now().timestamp() - 300; + let mut guard = stats_store_for_evict.write().await; + guard.retain(|_, entry| entry.last_seen >= cutoff); + } + } + } + }); + + if let Some(path) = &args.targets_file { + match target::load_targets_from_file(path).await { + Ok(initial_targets) => { + let mut guard = targets.write().await; + *guard = initial_targets; + tracing::info!(count = guard.len(), "loaded initial targets from file"); + } + Err(e) => { + tracing::warn!(error = %e, "failed to load targets file"); + } + } + + let tw = + hotreload::watch_targets_file(path.clone(), targets.clone(), shutdown_tx.subscribe()); + tokio::spawn(tw); + } else if std::env::var("KUBERNETES_SERVICE_HOST").is_ok() { + if let Some(k8s_targets) = k8s_discovery::k8s_pod_discovery().await { + let mut guard = targets.write().await; + *guard = k8s_targets.clone(); + tracing::info!(count = guard.len(), "discovered K8s pods as targets"); + } + } + + let scrape_filter = args + .scrape_apps + .as_ref() + .map(|s| s.split(',').map(|p| p.trim().to_string()).collect()); + + let scrape_targets = targets.clone(); + let scrape_store = store.clone(); + let scrape_metrics = metrics.clone(); + let scrape_http = http.clone(); + let loki_clone = loki.clone(); + let shutdown_tx_clone = shutdown_tx.clone(); + let scrape_interval = args.scrape_interval_secs; + let scrape_filter_clone = scrape_filter.clone(); + tokio::task::spawn_local(async move { + scrape_loop( + scrape_targets, + scrape_store, + scrape_metrics, + scrape_http, + scrape_interval, + scrape_filter_clone, + loki_clone, + shutdown_tx_clone.subscribe(), + ) + .await; + }); + + let log_shutdown = shutdown_tx.subscribe(); + let log_loki = loki.clone(); + tokio::task::spawn_local(async move { + log_collector(log_loki, log_shutdown).await; + }); + + let bind_addr: SocketAddr = ([0, 0, 0, 0], args.port).into(); + tracing::info!(addr = %bind_addr, "HTTP server starting"); + + let app_targets = targets.clone(); + let app_store = store.clone(); + let app_handle = prometheus_handle.clone(); + let loki_for_push: Option> = loki.map(Arc::new); + let app_stats = stats_store.clone(); + + let server = HttpServer::new(move || { + let targets = app_targets.clone(); + let store = app_store.clone(); + let handle = app_handle.clone(); + let stats_store = app_stats.clone(); + let loki_for_push: Option> = loki_for_push.clone(); + actix_web::App::new() + .app_data(web::Data::new(targets)) + .app_data(web::Data::new(store)) + .app_data(web::Data::new(handle)) + .app_data(web::Data::new(stats_store)) + .app_data(web::Data::new(loki_for_push)) + .route("/metrics", web::get().to(handle_metrics)) + .route("/api/v1/metrics", web::get().to(handle_metrics)) + .route("/api/v1/push", web::post().to(handle_push)) + .route("/api/v1/dashboard", web::get().to(handle_dashboard)) + .route("/api/v1/stats", web::get().to(handle_stats)) + .route("/health", web::get().to(handle_health)) + .route("/api/v1/health", web::get().to(handle_health)) + .route("/api/v1/targets", web::get().to(handle_targets)) + }) + .bind(&bind_addr)? + .run(); + + let server_handle = server.handle(); + + tokio::spawn(server); + + tokio::signal::ctrl_c().await.ok(); + tracing::info!("received Ctrl+C, shutting down"); + + let _ = shutdown_tx.send(()); + server_handle.stop(true).await; + + if let Some(guard) = otel_guard { + guard.shutdown().await; + } + + tracing::info!("metrics-aggregator stopped"); + Ok(()) +} + +fn init_otel_from_args(args: &args::Args) -> Option { + if args.no_otel { + return None; + } + + let endpoint = args + .otel_endpoint + .clone() + .or_else(|| std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok())?; + + match otel::init_otel(&endpoint, "metrics-aggregator") { + Ok(guard) => { + tracing::info!(endpoint = %endpoint, "OTLP tracing enabled"); + Some(guard) + } + Err(e) => { + tracing::warn!(error = %e, "OTLP init failed, continuing without traces"); + None + } + } +} + +fn init_loki_from_args(args: &args::Args) -> Option { + if args.no_loki { + return None; + } + + let url = args + .loki_url + .clone() + .or_else(|| std::env::var("LOKI_URL").ok())?; + + tracing::info!("Loki log forwarding enabled"); + Some(LokiForwarder::new(url)) +} + +async fn handle_metrics( + store: web::Data, + stats_store: web::Data, + handle: web::Data, +) -> HttpResponse { + let extra = vec![("aggregator_instance".to_string(), "default".to_string())]; + let scraped = render_aggregated_metrics(store, extra.clone()).await; + let pushed = render_pushed_metrics(stats_store).await; + let combined = format!("{}{}{}", handle.render(), scraped, pushed); + HttpResponse::Ok() + .content_type("text/plain; version=0.0.4; charset=utf-8") + .body(combined) +} + +async fn handle_health() -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(r#"{"status":"ok"}"#) +} + +async fn handle_targets(targets: web::Data>>>) -> HttpResponse { + let guard = targets.read().await; + let json = serde_json::to_string(&*guard).unwrap_or_default(); + HttpResponse::Ok() + .content_type("application/json") + .body(json) +} + +// ── Push endpoint payload ──────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PushPayload { + app: String, + #[serde(default)] + instance: String, + timestamp: i64, + #[serde(default)] + http: Option, + #[serde(default)] + system: Option, + #[serde(default)] + business: HashMap, + #[serde(default)] + token_usage: Option, + #[serde(default)] + tasks: Option, + #[serde(default)] + latency: HashMap, + #[serde(default)] + logs: Vec, +} + +async fn handle_push( + stats_store: web::Data, + loki: web::Data>>, + payload: web::Json, +) -> HttpResponse { + let app = payload.app.clone(); + + stats_store::merge_push_payload( + &stats_store, + &app, + &payload.instance, + payload.timestamp, + payload.http.as_ref(), + payload.system.as_ref(), + &payload.business, + payload.token_usage.as_ref(), + payload.tasks.as_ref(), + &payload.latency, + &payload.logs, + ).await; + + // Forward logs to Loki if configured + if !payload.logs.is_empty() { + if let Some(loki_fwd) = loki.as_ref() { + let entries: Vec = payload + .logs + .iter() + .map(|l| LokiEntry { + timestamp: chrono::DateTime::from_timestamp(l.timestamp, 0) + .unwrap_or_else(chrono::Utc::now), + line: format!("[{}] {}", l.level.to_lowercase(), l.message), + }) + .collect(); + if let Err(e) = loki_fwd.push(entries).await { + tracing::warn!(error = %e, "loki push on /push failed"); + } + } + } + + HttpResponse::Ok().body("ok") +} + +async fn scrape_loop( + targets: Arc>>, + store: MetricsStore, + metrics: AggMetrics, + http: HttpClient, + interval_secs: u64, + scrape_apps_filter: Option>, + _loki: Option, + mut shutdown: broadcast::Receiver<()>, +) { + let mut ticker = interval(Duration::from_secs(interval_secs)); + loop { + tokio::select! { + _ = shutdown.recv() => break, + _ = ticker.tick() => { + let targets_snapshot = targets.read().await.clone(); + let count = targets_snapshot.len() as u64; + metrics.targets_total.set(count as f64); + + let mut healthy_count = 0u64; + + for target in &targets_snapshot { + if let Some(ref filter) = scrape_apps_filter { + if !filter.contains(&target.name) { + continue; + } + } + + metrics.scrape_total.increment(1); + + match http.scrape(target).await { + ScrapeResult::Success(body, duration_ms) => { + metrics.scrape_success.increment(1); + metrics.scrape_duration.record(duration_ms); + + let parsed = scrape::parse_prometheus(&body); + update_store(store.clone(), &target.name, parsed).await; + healthy_count += 1; + } + ScrapeResult::Timeout => { + metrics.scrape_failures.increment(1); + metrics.scrape_errors_timeout.increment(1); + tracing::warn!(target = %target.name, "scrape timeout"); + } + ScrapeResult::ConnectionError(e) => { + metrics.scrape_failures.increment(1); + metrics.scrape_errors_connection.increment(1); + tracing::warn!(target = %target.name, error = %e, "scrape connection error"); + } + ScrapeResult::HttpError(status) => { + metrics.scrape_failures.increment(1); + tracing::warn!(target = %target.name, status = status, "scrape HTTP error"); + } + } + } + + metrics.targets_healthy.set(healthy_count as f64); + } + } + } +} + +async fn update_store(store: MetricsStore, target_name: &str, metrics: Vec) { + let mut guard = store.write().await; + guard.insert(target_name.to_string(), metrics); +} + +async fn render_aggregated_metrics( + store: web::Data, + extra_group_labels: Vec<(String, String)>, +) -> String { + let guard = store.read().await; + let mut output = String::new(); + + for (target_name, metrics) in guard.iter() { + for metric in metrics { + let mut labels = metric.labels.clone(); + labels.insert( + "aggregated_by".to_string(), + "metrics-aggregator".to_string(), + ); + labels.insert("source_target".to_string(), target_name.clone()); + for (k, v) in &extra_group_labels { + labels.insert(k.clone(), v.clone()); + } + + let label_str = if labels.is_empty() { + String::new() + } else { + let pairs: Vec = labels + .iter() + .map(|(k, v)| { + format!( + r#"{}="{}""#, + k, + v.replace('\\', "\\\\").replace('"', "\\\"") + ) + }) + .collect(); + format!("{{{}}}", pairs.join(",")) + }; + + let _ = writeln!(&mut output, "{}{} {}", metric.name, label_str, metric.value); + } + } + + output +} + +async fn render_pushed_metrics(stats_store: web::Data) -> String { + let guard = stats_store.read().await; + let mut output = String::new(); + + for (app_name, entry) in guard.iter() { + let labels = [ + format!(r#"app="{}""#, app_name), + "aggregated_by".to_string(), + "metrics-aggregator".to_string(), + "push_source=true".to_string(), + ]; + + let label_str = format!("{{{}}}", labels.join(",")); + let h = &entry; + + let _ = writeln!( + &mut output, + "push_http_requests_total{} {}", + label_str, + h.requests_total + ); + let _ = writeln!( + &mut output, + "push_http_request_duration_ms_total{} {}", + label_str, + h.request_duration_ms_total + ); + let _ = writeln!(&mut output, "push_http_requests_2xx{} {}", label_str, h.requests_2xx); + let _ = writeln!(&mut output, "push_http_requests_4xx{} {}", label_str, h.requests_4xx); + let _ = writeln!(&mut output, "push_http_requests_5xx{} {}", label_str, h.requests_5xx); + + for (endpoint, &count) in &h.endpoints { + let sanitized = endpoint.replace([' ', '/'], "_").to_lowercase(); + let ep_labels = format!(r#"app="{}",endpoint="{}",aggregated_by="metrics-aggregator",push_source="true""#, app_name, sanitized); + let _ = writeln!(&mut output, "push_http_endpoint_requests_total{{{}}} {}", ep_labels, count); + } + + // System metrics in Prometheus format + let sys_labels = format!(r#"app="{}",aggregated_by="metrics-aggregator""#, app_name); + let _ = writeln!(&mut output, "system_cpu_usage_percent{{{}}} {}", sys_labels, h.cpu_usage_percent); + let _ = writeln!(&mut output, "system_memory_used_mb{{{}}} {}", sys_labels, h.memory_used_mb); + let _ = writeln!(&mut output, "system_memory_total_mb{{{}}} {}", sys_labels, h.memory_total_mb); + let _ = writeln!(&mut output, "system_uptime_secs{{{}}} {}", sys_labels, h.uptime_secs); + + // Business counters + for (counter_name, value) in &h.business { + let biz_labels = format!(r#"app="{}",aggregated_by="metrics-aggregator""#, app_name); + let _ = writeln!(&mut output, "{}{{{}}} {}", counter_name, biz_labels, value); + } + + // Token usage + let ai_labels = format!(r#"app="{}",aggregated_by="metrics-aggregator""#, app_name); + let _ = writeln!(&mut output, "ai_input_tokens_total{{{}}} {}", ai_labels, h.ai_input_tokens_total); + let _ = writeln!(&mut output, "ai_output_tokens_total{{{}}} {}", ai_labels, h.ai_output_tokens_total); + let _ = writeln!(&mut output, "ai_calls_total{{{}}} {}", ai_labels, h.ai_calls_total); + + // Latency per endpoint + for (endpoint, lat) in &h.latency { + let lat_labels = format!(r#"app="{}",endpoint="{}",aggregated_by="metrics-aggregator""#, app_name, endpoint); + let _ = writeln!(&mut output, "latency_p99_ms{{{}}} {}", lat_labels, lat.p99_ms); + let _ = writeln!(&mut output, "latency_p90_ms{{{}}} {}", lat_labels, lat.p90_ms); + let _ = writeln!(&mut output, "latency_p50_ms{{{}}} {}", lat_labels, lat.p50_ms); + let _ = writeln!(&mut output, "latency_max_ms{{{}}} {}", lat_labels, lat.max_ms); + } + } + + output +} + +// ── JSON API handlers ──────────────────────────────────────────────────────── + +async fn handle_dashboard(stats_store: web::Data) -> HttpResponse { + let dashboard = stats_store::build_dashboard(&stats_store).await; + let json = serde_json::to_string(&dashboard).unwrap_or_default(); + HttpResponse::Ok() + .content_type("application/json") + .body(json) +} + +async fn handle_stats(stats_store: web::Data) -> HttpResponse { + // Returns per-app stats as JSON + let guard = stats_store.read().await; + let json = serde_json::to_string(&*guard).unwrap_or_default(); + HttpResponse::Ok() + .content_type("application/json") + .body(json) +} + +async fn log_collector(loki: Option, mut shutdown: broadcast::Receiver<()>) { + let stdin = tokio::io::stdin(); + let mut reader = tokio::io::BufReader::new(stdin); + let mut interval_tick = interval(Duration::from_secs(1)); + let mut batch: Vec = Vec::with_capacity(100); + let mut line_buf = String::new(); + + loop { + tokio::select! { + _ = shutdown.recv() => break, + _ = interval_tick.tick() => { + if !batch.is_empty() { + if let Some(ref loki) = loki { + if let Err(e) = loki.push(std::mem::take(&mut batch)).await { + tracing::warn!(error = %e, "Loki push failed"); + } + } + } + } + _ = async { line_buf.clear(); reader.read_line(&mut line_buf).await.ok() } => { + if !line_buf.is_empty() { + let line = line_buf.trim_end().to_string(); + if !line.is_empty() { + batch.push(LokiEntry { + timestamp: chrono::Utc::now(), + line, + }); + if batch.len() >= 100 { + if let Some(ref loki) = loki { + if let Err(e) = loki.push(std::mem::take(&mut batch)).await { + tracing::warn!(error = %e, "Loki push failed"); + } + } + } + } + } + } + } + } +} diff --git a/apps/metrics/src/metrics.rs b/apps/metrics/src/metrics.rs new file mode 100644 index 0000000..68a1adc --- /dev/null +++ b/apps/metrics/src/metrics.rs @@ -0,0 +1,99 @@ +use metrics::{describe_counter, describe_gauge, describe_histogram, Counter, Gauge, Histogram, Unit}; + +pub fn init() { + describe_gauge!( + "aggregator_targets_total", + Unit::Count, + "Total number of scrape targets known to the aggregator" + ); + describe_gauge!( + "aggregator_targets_healthy", + Unit::Count, + "Number of scrape targets that responded last scrape" + ); + describe_counter!( + "aggregator_scrape_total", + Unit::Count, + "Total number of scrape attempts" + ); + describe_counter!( + "aggregator_scrape_success", + Unit::Count, + "Successful scrapes" + ); + describe_counter!( + "aggregator_scrape_failures", + Unit::Count, + "Failed scrape attempts" + ); + describe_counter!( + "aggregator_scrape_errors_parse", + Unit::Count, + "Scrape failures due to parse errors" + ); + describe_counter!( + "aggregator_scrape_errors_timeout", + Unit::Count, + "Scrape failures due to timeout" + ); + describe_counter!( + "aggregator_scrape_errors_connection", + Unit::Count, + "Scrape failures due to connection errors" + ); + describe_counter!( + "aggregator_targets_discovered", + Unit::Count, + "Total targets discovered" + ); + describe_counter!( + "aggregator_targets_lost", + Unit::Count, + "Total targets that disappeared" + ); + describe_histogram!( + "aggregator_scrape_duration_ms", + Unit::Milliseconds, + "Scrape duration in milliseconds" + ); +} + +#[derive(Clone)] +#[allow(dead_code)] +pub struct AggMetrics { + pub targets_total: Gauge, + pub targets_healthy: Gauge, + pub scrape_total: Counter, + pub scrape_success: Counter, + pub scrape_failures: Counter, + pub scrape_errors_parse: Counter, + pub scrape_errors_timeout: Counter, + pub scrape_errors_connection: Counter, + pub targets_discovered: Counter, + pub targets_lost: Counter, + pub scrape_duration: Histogram, +} + +impl Default for AggMetrics { + fn default() -> Self { + Self { + targets_total: metrics::gauge!("aggregator_targets_total"), + targets_healthy: metrics::gauge!("aggregator_targets_healthy"), + scrape_total: metrics::counter!("aggregator_scrape_total"), + scrape_success: metrics::counter!("aggregator_scrape_success"), + scrape_failures: metrics::counter!("aggregator_scrape_failures"), + scrape_errors_parse: metrics::counter!("aggregator_scrape_errors_parse"), + scrape_errors_timeout: metrics::counter!("aggregator_scrape_errors_timeout"), + scrape_errors_connection: metrics::counter!("aggregator_scrape_errors_connection"), + targets_discovered: metrics::counter!("aggregator_targets_discovered"), + targets_lost: metrics::counter!("aggregator_targets_lost"), + scrape_duration: metrics::histogram!("aggregator_scrape_duration_ms"), + } + } +} + +impl AggMetrics { + pub fn new() -> Self { + Self::default() + } +} diff --git a/apps/metrics/src/otel.rs b/apps/metrics/src/otel.rs new file mode 100644 index 0000000..2d7325c --- /dev/null +++ b/apps/metrics/src/otel.rs @@ -0,0 +1,40 @@ +use anyhow::Context; +use opentelemetry::trace::TracerProvider; +use opentelemetry_otlp::{SpanExporter, WithExportConfig}; +use opentelemetry_sdk::trace as sdktrace; +use tracing_opentelemetry::layer; +use tracing_subscriber::prelude::*; + +pub struct OtelGuard { + provider: sdktrace::SdkTracerProvider, +} + +impl OtelGuard { + pub async fn shutdown(self) { + if let Err(e) = self.provider.shutdown() { + tracing::warn!(error = %e, "OTLP shutdown error"); + } + } +} + +pub fn init_otel(endpoint: &str, service_name: &str) -> anyhow::Result { + let exporter = SpanExporter::builder() + .with_http() + .with_endpoint(endpoint) + .build() + .context("build OTLP exporter")?; + + let tracer_provider = sdktrace::SdkTracerProvider::builder() + .with_batch_exporter(exporter) + .build(); + + let tracer = tracer_provider.tracer(service_name.to_string()); + let otel_layer = layer().with_tracer(tracer); + + tracing_subscriber::registry() + .with(otel_layer) + .try_init() + .context("install OTLP tracing subscriber")?; + + Ok(OtelGuard { provider: tracer_provider }) +} \ No newline at end of file diff --git a/apps/metrics/src/scrape.rs b/apps/metrics/src/scrape.rs new file mode 100644 index 0000000..2b3cf98 --- /dev/null +++ b/apps/metrics/src/scrape.rs @@ -0,0 +1,135 @@ +use awc::Client; +use std::collections::HashMap; + +use crate::target::ScrapeTarget; + +#[derive(Clone)] +pub struct HttpClient { + client: Client, +} + +impl HttpClient { + pub fn new(timeout_secs: u64) -> Self { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .finish(); + Self { client } + } + + pub async fn scrape(&self, target: &ScrapeTarget) -> ScrapeResult { + let start = std::time::Instant::now(); + let url = target.url(); + + let mut resp = match self.client.get(url).send().await { + Ok(resp) => resp, + Err(e) => { + let msg = e.to_string(); + if msg.contains("timeout") || msg.contains("TimedOut") || msg.contains("timed out") + { + return ScrapeResult::Timeout; + } + return ScrapeResult::ConnectionError(msg); + } + }; + + if !resp.status().is_success() { + return ScrapeResult::HttpError(resp.status().as_u16()); + } + + let body = match resp.body().await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(e) => return ScrapeResult::ConnectionError(e.to_string()), + }; + + let scrape_ms = start.elapsed().as_millis() as f64; + ScrapeResult::Success(body, scrape_ms) + } +} + +pub enum ScrapeResult { + Success(String, f64), + Timeout, + ConnectionError(String), + HttpError(u16), +} + +#[derive(Clone, Debug)] +pub struct PromMetric { + pub name: String, + pub value: f64, + pub labels: HashMap, +} + +pub fn parse_prometheus(body: &str) -> Vec { + let mut metrics = Vec::new(); + for line in body.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let (name_and_labels, value_str) = match line.find(' ') { + Some(pos) => (&line[..pos], &line[pos + 1..]), + None => continue, + }; + + let value: f64 = match value_str + .split_whitespace() + .next() + .and_then(|v| v.parse().ok()) + { + Some(v) => v, + None => continue, + }; + + let (metric_name, labels) = if let Some(brace) = name_and_labels.find('{') { + let name = &name_and_labels[..brace]; + let label_str = &name_and_labels[brace + 1..name_and_labels.len() - 1]; + let labels = parse_labels(label_str); + (name.to_string(), labels) + } else { + (name_and_labels.to_string(), HashMap::new()) + }; + + metrics.push(PromMetric { + name: metric_name, + value, + labels, + }); + } + metrics +} + +pub fn parse_labels(s: &str) -> HashMap { + let mut labels = HashMap::new(); + let mut remaining = s; + while !remaining.is_empty() { + if let Some(eq) = remaining.find('=') { + let key = remaining[..eq].trim().to_string(); + remaining = &remaining[eq + 1..]; + let (value, rest) = if remaining.starts_with('"') { + let end = remaining[1..] + .find('"') + .map(|p| p + 1) + .unwrap_or(remaining.len()); + (&remaining[1..end], &remaining[end + 1..]) + } else if remaining.starts_with('\'') { + let end = remaining[1..] + .find('\'') + .map(|p| p + 1) + .unwrap_or(remaining.len()); + (&remaining[1..end], &remaining[end + 1..]) + } else { + let end = remaining + .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') + .unwrap_or(remaining.len()); + (&remaining[..end], &remaining[end..]) + }; + labels.insert(key, value.to_string()); + remaining = rest.trim_start_matches(',').trim_start(); + } else { + break; + } + } + labels +} diff --git a/apps/metrics/src/stats_store.rs b/apps/metrics/src/stats_store.rs new file mode 100644 index 0000000..e384cd2 --- /dev/null +++ b/apps/metrics/src/stats_store.rs @@ -0,0 +1,210 @@ +//! Stats store: receives expanded push payloads from all apps, +//! aggregates over time, computes derived statistics (p99 etc), +//! and provides JSON API for external consumption. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use serde::Serialize; + +/// Per-app, per-instance aggregated stats entry. +#[derive(Debug, Clone, Default, Serialize)] +pub struct AppStats { + /// Last seen timestamp. + pub last_seen: i64, + /// Number of push samples received. + pub sample_count: u64, + + // ── HTTP ───────────────────────────────────────────────────── + pub requests_total: u64, + pub request_duration_ms_total: u64, + pub requests_2xx: u64, + pub requests_4xx: u64, + pub requests_5xx: u64, + pub endpoints: HashMap, + + // ── System ─────────────────────────────────────────────────── + pub cpu_usage_percent: f32, + pub memory_used_mb: u64, + pub memory_total_mb: u64, + pub uptime_secs: u64, + + // ── Business counters ──────────────────────────────────────── + pub business: HashMap, + + // ── Token usage ────────────────────────────────────────────── + pub ai_input_tokens_total: i64, + pub ai_output_tokens_total: i64, + pub ai_calls_total: i64, + pub ai_calls_success: i64, + pub ai_calls_failure: i64, + pub token_by_model: HashMap, + + // ── Tasks ──────────────────────────────────────────────────── + pub tasks_queued: i64, + pub tasks_running: i64, + pub tasks_completed: i64, + pub tasks_failed: i64, + + // ── Latency ────────────────────────────────────────────────── + pub latency: HashMap, + + // ── Logs ───────────────────────────────────────────────────── + #[serde(skip_serializing)] + pub logs: Vec<(i64, String)>, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct ModelTokenStats { + pub input_tokens: i64, + pub output_tokens: i64, + pub calls: i64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct LatencyStats { + pub p50_ms: f64, + pub p90_ms: f64, + pub p99_ms: f64, + pub max_ms: f64, + pub count: u64, +} + +/// The global stats store: app_name → AppStats. +pub type StatsStore = Arc>>; + +/// Merge a new push payload into the stats store. +pub async fn merge_push_payload( + store: &StatsStore, + app: &str, + _instance: &str, + timestamp: i64, + http: Option<&observability::push::HttpPayload>, + system: Option<&observability::push::SystemPayload>, + business: &HashMap, + token_usage: Option<&observability::push::TokenUsagePayload>, + tasks: Option<&observability::push::TaskStatsPayload>, + latency: &HashMap, + logs: &[observability::push::LogEntry], +) { + // Use app_name as key (merge across instances for aggregation) + let mut guard = store.write().await; + let entry = guard.entry(app.to_string()).or_default(); + entry.last_seen = timestamp; + entry.sample_count += 1; + + // HTTP — accumulate (not replace, so we get totals over time) + if let Some(http) = http { + entry.requests_total = http.requests_total; + entry.request_duration_ms_total = http.request_duration_ms_total; + entry.requests_2xx = http.requests_2xx; + entry.requests_4xx = http.requests_4xx; + entry.requests_5xx = http.requests_5xx; + for (ep, count) in &http.endpoints { + *entry.endpoints.entry(ep.clone()).or_insert(0) = *count; + } + } + + // System — replace (current snapshot, not cumulative) + if let Some(sys) = system { + entry.cpu_usage_percent = sys.cpu_usage_percent; + entry.memory_used_mb = sys.memory_used_mb; + entry.memory_total_mb = sys.memory_total_mb; + entry.uptime_secs = sys.uptime_secs; + } + + // Business — replace with latest snapshot + entry.business = business.clone(); + + // Token usage — replace with latest + if let Some(tu) = token_usage { + entry.ai_input_tokens_total = tu.ai_input_tokens_total; + entry.ai_output_tokens_total = tu.ai_output_tokens_total; + entry.ai_calls_total = tu.ai_calls_total; + entry.ai_calls_success = tu.ai_calls_success; + entry.ai_calls_failure = tu.ai_calls_failure; + for (model, usage) in &tu.by_model { + let ms = entry.token_by_model.entry(model.clone()).or_default(); + ms.input_tokens = usage.input_tokens; + ms.output_tokens = usage.output_tokens; + ms.calls = usage.calls; + } + } + + // Tasks — replace with latest + if let Some(t) = tasks { + entry.tasks_queued = t.queued; + entry.tasks_running = t.running; + entry.tasks_completed = t.completed; + entry.tasks_failed = t.failed; + } + + // Latency — replace with latest snapshots + for (endpoint, snap) in latency { + let ls = entry.latency.entry(endpoint.clone()).or_default(); + ls.p50_ms = snap.p50_ms; + ls.p90_ms = snap.p90_ms; + ls.p99_ms = snap.p99_ms; + ls.max_ms = snap.max_ms; + ls.count = snap.count; + } + + // Logs — append (keep last 300 lines) + for log in logs { + entry.logs.push((log.timestamp, format!("[{}] {}", log.level.to_lowercase(), log.message))); + } + let cutoff = chrono::Utc::now().timestamp() - 300; + entry.logs.retain(|(ts, _)| *ts >= cutoff); +} + +/// Dashboard response combining all apps' stats. +#[derive(Debug, Serialize)] +pub struct DashboardResponse { + /// Timestamp of this snapshot. + pub timestamp: i64, + /// Total number of app instances reporting. + pub app_count: u64, + /// Per-app aggregated stats. + pub apps: HashMap, + /// Derived: average p99 latency across all apps. + pub avg_p99_ms: f64, + /// Derived: total tokens consumed across all apps. + pub total_input_tokens: i64, + pub total_output_tokens: i64, + /// Derived: total AI calls across all apps. + pub total_ai_calls: i64, +} + +/// Build the dashboard response from the stats store. +pub async fn build_dashboard(store: &StatsStore) -> DashboardResponse { + let guard = store.read().await; + + let mut avg_p99 = 0.0; + let mut p99_count = 0; + let mut total_input = 0i64; + let mut total_output = 0i64; + let mut total_calls = 0i64; + + for (_, stats) in guard.iter() { + total_input += stats.ai_input_tokens_total; + total_output += stats.ai_output_tokens_total; + total_calls += stats.ai_calls_total; + + for (_, lat) in &stats.latency { + avg_p99 += lat.p99_ms; + p99_count += 1; + } + } + + let avg_p99_ms = if p99_count > 0 { avg_p99 / p99_count as f64 } else { 0.0 }; + + DashboardResponse { + timestamp: chrono::Utc::now().timestamp(), + app_count: guard.len() as u64, + apps: guard.clone(), + avg_p99_ms, + total_input_tokens: total_input, + total_output_tokens: total_output, + total_ai_calls: total_calls, + } +} \ No newline at end of file diff --git a/apps/metrics/src/target.rs b/apps/metrics/src/target.rs new file mode 100644 index 0000000..f927b01 --- /dev/null +++ b/apps/metrics/src/target.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; +use anyhow::Context; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ScrapeTarget { + pub name: String, + pub addr: String, + #[serde(default = "default_metrics_path")] + pub metrics_path: String, + #[serde(default)] + pub labels: HashMap, +} + +fn default_metrics_path() -> String { + "/metrics".to_string() +} + +impl ScrapeTarget { + pub fn url(&self) -> String { + if self.metrics_path.starts_with("http") { + self.metrics_path.clone() + } else { + format!("http://{}{}", self.addr, self.metrics_path) + } + } +} + +pub async fn load_targets_from_file(path: &str) -> anyhow::Result> { + let content = tokio::fs::read_to_string(path).await.context("read targets file")?; + let targets: Vec = serde_json::from_str(&content) + .with_context(|| format!("parse targets file {path}"))?; + Ok(targets) +} diff --git a/apps/static/Cargo.toml b/apps/static/Cargo.toml index b8a7ad6..5e6c885 100644 --- a/apps/static/Cargo.toml +++ b/apps/static/Cargo.toml @@ -7,6 +7,8 @@ edition.workspace = true actix-web = { workspace = true } actix-files = { workspace = true } actix-cors = { workspace = true } +observability = { workspace = true } +metrics-exporter-prometheus = "0.13" tokio = { workspace = true, features = ["full"] } futures = { workspace = true } serde = { workspace = true } diff --git a/apps/static/src/main.rs b/apps/static/src/main.rs index fb0f39d..4b572ea 100644 --- a/apps/static/src/main.rs +++ b/apps/static/src/main.rs @@ -5,8 +5,10 @@ use actix_web::{http::header, web, App, HttpResponse, HttpServer}; use futures::future::LocalBoxFuture; use log::info; use std::path::PathBuf; +use std::sync::Arc; use std::task::{Context, Poll}; use std::time::Instant; +use observability::{init_tracing_subscriber, install_recorder, HttpMetrics, push::MetricsPusher}; /// Static file server for avatar, blob, and other static files /// Serves files from /data/{type} directories @@ -119,7 +121,16 @@ where #[actix_web::main] async fn main() -> anyhow::Result<()> { - env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); + init_tracing_subscriber("info", false); + let prometheus_handle = Arc::new(install_recorder()); + let http_metrics = Arc::new(HttpMetrics::new()); + + // Metrics pusher: periodically push all metrics to apps/metrics aggregator + if let Some(push_url) = std::env::var("METRICS_PUSH_URL").ok() { + let pusher = MetricsPusher::new(&push_url, "static"); + pusher.spawn(http_metrics.clone(), prometheus_handle.clone(), std::time::Duration::from_secs(15)); + info!("Metrics pusher started (interval 15s, url: {})", push_url); + } let cfg = StaticConfig::from_env(); let bind = std::env::var("STATIC_BIND").unwrap_or_else(|_| "0.0.0.0:8081".to_string()); @@ -142,6 +153,8 @@ async fn main() -> anyhow::Result<()> { let root = root.clone(); let cors = if cors_enabled { + // WARNING: allow_any_origin is intentional for static asset serving (CDN mode) + // Ensure no sensitive files are served from this directory Cors::default() .allow_any_origin() .allowed_methods(vec!["GET", "HEAD", "OPTIONS"]) diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..3f7b71f --- /dev/null +++ b/build.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ── helpers ────────────────────────────────────────────────────────── +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERR]${NC} $*"; exit 1; } + +command_exists() { command -v "$1" &>/dev/null; } + +# ── 1. Rust ───────────────────────────────────────────────────────── +if command_exists rustc; then + log "Rust $(rustc --version)" +else + warn "Rust not found, installing via rustup..." + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + # shellcheck disable=SC1091 + source "$HOME/.cargo/env" + log "Rust installed: $(rustc --version)" +fi + +# ── 2. Node.js ────────────────────────────────────────────────────── +if command_exists node; then + log "Node.js $(node --version)" +else + warn "Node.js not found, installing via nvm..." + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash + # shellcheck disable=SC1090 + export NVM_DIR="${HOME}/.nvm" + [ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh" + nvm install --lts + log "Node.js installed: $(node --version)" +fi + +# ── 2b. Bun ───────────────────────────────────────────────────────── +if command_exists bun; then + log "Bun $(bun --version)" +else + warn "Bun not found, installing..." + curl -fsSL https://bun.sh/install | bash + # shellcheck disable=SC1091 + [ -s "$HOME/.bun/_bun" ] && export PATH="$HOME/.bun/bin:$PATH" + log "Bun installed: $(bun --version)" +fi + +# ── 3. Docker ─────────────────────────────────────────────────────── +if command_exists docker; then + log "Docker $(docker --version)" +else + warn "Docker not found, installing..." + curl -fsSL https://get.docker.com | sh + log "Docker installed: $(docker --version)" +fi + +# ── 4. Frontend build ─────────────────────────────────────────────── +log "Running bun install..." +bun install + +log "Running bun run build..." +bun run build + +# ── 5. Rust build ─────────────────────────────────────────────────── +log "Running cargo build --release --workspace..." +cargo build --release --workspace + +# ── 6. Docker images ──────────────────────────────────────────────── +TAG=$(git rev-parse --short HEAD) +log "Building Docker images with tag: $TAG" + +IMAGES=( + "docker/app.Dockerfile app:$TAG" + "docker/email.Dockerfile email-worker:$TAG" + "docker/githook.Dockerfile git-hook:$TAG" + "docker/gitserver.Dockerfile gitserver:$TAG" + "docker/metrics.Dockerfile metrics-aggregator:$TAG" + "docker/static.Dockerfile static-server:$TAG" + "docker/gingress.Dockerfile gingress:$TAG" +) + +for entry in "${IMAGES[@]}"; do + read -r dockerfile tag <<< "$entry" + log "Building $tag..." + docker build -f "$dockerfile" -t "$tag" . +done + +log "All images built successfully." +docker images | grep -E "app|email-worker|git-hook|gitserver|metrics-aggregator|static-server|gingress" | grep "$TAG" || true diff --git a/bun.lock b/bun.lock index ce6ec96..5753437 100644 --- a/bun.lock +++ b/bun.lock @@ -7,8 +7,9 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@fontsource-variable/geist": "^5.2.8", + "@lobehub/icons": "^5.8.0", "@reduxjs/toolkit": "^2.11.2", - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-form": "^1.29.1", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-pacer": "^0.22.0", @@ -16,13 +17,15 @@ "@tanstack/react-router": "^1.169.1", "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "alova": "^3.5.1", "axios": "^1.15.2", - "casl": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.20", + "dexie": "^4.4.2", + "dexie-react-hooks": "^4.4.0", "dnd-kit": "^0.0.2", "embla-carousel-react": "^8.6.0", "idb": "^8.0.3", @@ -38,10 +41,13 @@ "react-dom": "^19.2.4", "react-hook-form": "^7.75.0", "react-i18next": "^17.0.6", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-resizable-panels": "^4.10.0", "react-router-dom": "^7.14.2", + "react-virtuoso": "^4.18.6", "recharts": "^3.8.1", + "remark-gfm": "^4.0.1", "shadcn": "^4.6.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", @@ -56,6 +62,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@tailwindcss/postcss": "^4.2.4", + "@tailwindcss/vite": "^4.2.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -75,8 +83,26 @@ }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@alova/shared": ["@alova/shared@1.3.2", "https://registry.npmmirror.com/@alova/shared/-/shared-1.3.2.tgz", {}, "sha512-1XvDLWgYpVZ99MmLl1f3Fw4T6S6pPYk5afz5cwRVjuq8JXEGsDn9IygDKfvRyWqkqCBx7Jif07LIct1O+MVEow=="], + "@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="], + + "@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="], + + "@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="], + + "@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="], + + "@ant-design/icons": ["@ant-design/icons@6.2.2", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ=="], + + "@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="], + + "@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="], + + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.3", "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.3.tgz", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], @@ -143,14 +169,66 @@ "@base-ui/utils": ["@base-ui/utils@0.2.8", "https://registry.npmmirror.com/@base-ui/utils/-/utils-0.2.8.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ=="], + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@12.0.0", "", { "dependencies": { "@chevrotain/gast": "12.0.0", "@chevrotain/types": "12.0.0" } }, "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg=="], + + "@chevrotain/gast": ["@chevrotain/gast@12.0.0", "", { "dependencies": { "@chevrotain/types": "12.0.0" } }, "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@12.0.0", "", {}, "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="], + + "@chevrotain/types": ["@chevrotain/types@12.0.0", "", {}, "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="], + + "@chevrotain/utils": ["@chevrotain/utils@12.0.0", "", {}, "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="], + "@commander-js/extra-typings": ["@commander-js/extra-typings@14.0.0", "https://registry.npmmirror.com/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", { "peerDependencies": { "commander": "~14.0.0" } }, "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg=="], "@date-fns/tz": ["@date-fns/tz@1.4.1", "https://registry.npmmirror.com/@date-fns/tz/-/tz-1.4.1.tgz", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.64.0", "https://registry.npmmirror.com/@dotenvx/dotenvx/-/dotenvx-1.64.0.tgz", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.4", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-6+xRpZaWuHXEqnhBjae+VmQI9Uaqw5Uzu/ScpO+W7ww9Zp3lHSNBoNjFcUxhrCyc7pRGQzyDjhKzloqrPHERiQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "https://registry.npmmirror.com/@ecies/ciphers/-/ciphers-0.2.6.tgz", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], + + "@emoji-mart/react": ["@emoji-mart/react@1.1.1", "", { "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" } }, "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="], + + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/css": ["@emotion/css@11.13.5", "", { "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2" } }, "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w=="], + + "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], + + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -225,6 +303,8 @@ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + "@floating-ui/react": ["@floating-ui/react@0.27.19", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog=="], + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], @@ -233,6 +313,8 @@ "@gerrit0/mini-shiki": ["@gerrit0/mini-shiki@3.23.0", "https://registry.npmmirror.com/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", { "dependencies": { "@shikijs/engine-oniguruma": "^3.23.0", "@shikijs/langs": "^3.23.0", "@shikijs/themes": "^3.23.0", "@shikijs/types": "^3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg=="], + "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -245,6 +327,10 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="], + "@inquirer/ansi": ["@inquirer/ansi@2.0.5", "https://registry.npmmirror.com/@inquirer/ansi/-/ansi-2.0.5.tgz", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="], "@inquirer/confirm": ["@inquirer/confirm@6.0.12", "https://registry.npmmirror.com/@inquirer/confirm/-/confirm-6.0.12.tgz", { "dependencies": { "@inquirer/core": "^11.1.9", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og=="], @@ -265,6 +351,24 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + + "@lobehub/emojilib": ["@lobehub/emojilib@1.0.0", "", {}, "sha512-s9KnjaPjsEefaNv150G3aifvB+J3P4eEKG+epY9zDPS2BeB6+V2jELWqAZll+nkogMaVovjEE813z3V751QwGw=="], + + "@lobehub/fluent-emoji": ["@lobehub/fluent-emoji@4.1.0", "", { "dependencies": { "@lobehub/emojilib": "^1.0.0", "antd-style": "^4.1.0", "emoji-regex": "^10.6.0", "es-toolkit": "^1.43.0", "lucide-react": "^0.562.0", "url-join": "^5.0.0" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-R1MB2lfUkDvB7XAQdRzY75c1dx/tB7gEvBPaEEMarzKfCJWmXm7rheS6caVzmgwAlq5sfmTbxPL+un99sp//Yw=="], + + "@lobehub/icons": ["@lobehub/icons@5.8.0", "", { "dependencies": { "antd-style": "^4.1.0", "es-toolkit": "^1.45.1", "lucide-react": "^0.469.0", "polished": "^4.3.1" }, "peerDependencies": { "@lobehub/ui": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-pt06WqZdIQzagevf+aX4MlEOrBtAPHAvo2pq3sIoRikzzUCND/zhnXG8pFPjoxhnkkvUCWXWm/8wjnFasufW7A=="], + + "@lobehub/ui": ["@lobehub/ui@5.10.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@base-ui/react": "1.0.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@emotion/is-prop-valid": "^1.4.0", "@floating-ui/react": "^0.27.19", "@giscus/react": "^3.1.0", "@mdx-js/mdx": "^3.1.1", "@mdx-js/react": "^3.1.1", "@pierre/diffs": "^1.1.19", "@radix-ui/react-slot": "^1.2.4", "@shikijs/core": "^4.0.2", "@shikijs/transformers": "^4.0.2", "@splinetool/runtime": "0.9.526", "ahooks": "^3.9.7", "antd-style": "^4.1.0", "chroma-js": "^3.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", "emoji-mart": "^5.6.0", "es-toolkit": "^1.46.0", "fast-deep-equal": "^3.1.3", "immer": "^11.1.4", "katex": "^0.16.45", "leva": "^0.10.1", "lucide-react": "^1.11.0", "marked": "^17.0.6", "mermaid": "^11.14.0", "motion": "^12.38.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.3.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.12.0", "rc-input-number": "^9.5.0", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^15.1.0", "react-error-boundary": "^6.1.1", "react-hotkeys-hook": "^5.2.4", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.3", "react-zoom-pan-pinch": "^3.7.0", "rehype-github-alerts": "^4.2.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-cjk-friendly": "^2.0.1", "remark-gfm": "^4.0.1", "remark-github": "^12.0.0", "remark-math": "^6.0.0", "remend": "^1.3.0", "shiki": "^4.0.2", "shiki-stream": "^0.1.4", "swr": "^2.4.1", "ts-md5": "^2.0.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^13.0.0", "virtua": "^0.49.1" }, "peerDependencies": { "@lobehub/fluent-emoji": "^4.0.0", "@lobehub/icons": "^5.0.0", "antd": "^6.1.1", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-ozLKbvOXMgTg/SXt0frPu6HM+PjTu+KpHwoSlK7uqURHHs1ENRlzm3/KuniGZ/V0U5LFIJx2ybCWZaZblgvzKA=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@mswjs/interceptors": ["@mswjs/interceptors@0.41.8", "https://registry.npmmirror.com/@mswjs/interceptors/-/interceptors-0.41.8.tgz", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-pRLMNKTSGRoLq+KnEB/7OY5vijw1XmcheAAOiv6pj7W1FG32kAGqj1C/RK/cqxRGr1Fh+zBi8sDur8kj3EQv6A=="], @@ -309,6 +413,12 @@ "@orval/zod": ["@orval/zod@8.9.0", "https://registry.npmmirror.com/@orval/zod/-/zod-8.9.0.tgz", { "dependencies": { "@orval/core": "8.9.0", "remeda": "^2.33.6" } }, "sha512-KZ/JSDHIlt//ErwVqzw5DxjxUYoOq4wj1MFfuHMUBTJL9cPlXbyChgWE/rEMYOJI4i1zLjNSJfcQASuQad3zDw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.20", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-lLi+3sLCm3QDd5/aLO9pw+WbF6UzhrkWm2oTZ5WZJTGemOyUNRJ4DDhcEKmVusu4C4bXx9Nssh6fF+wQcapb5w=="], + + "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + + "@primer/octicons": ["@primer/octicons@19.25.0", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-E0eMV8nXexrs7Vro7PdS8v/JfvvYCMh8HN6CXJ9l8fk9atZaY05fVUcyiAh5KjEJu7IxdFy4URfHGpM7+iOl1A=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "https://registry.npmmirror.com/@radix-ui/number/-/number-1.1.1.tgz", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.1.3.tgz", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -429,6 +539,92 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.1.tgz", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="], + + "@rc-component/cascader": ["@rc-component/cascader@1.14.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ=="], + + "@rc-component/checkbox": ["@rc-component/checkbox@2.0.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ=="], + + "@rc-component/collapse": ["@rc-component/collapse@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw=="], + + "@rc-component/color-picker": ["@rc-component/color-picker@3.1.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg=="], + + "@rc-component/context": ["@rc-component/context@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw=="], + + "@rc-component/dialog": ["@rc-component/dialog@1.8.4", "", { "dependencies": { "@rc-component/motion": "^1.1.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw=="], + + "@rc-component/drawer": ["@rc-component/drawer@1.4.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.9.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q=="], + + "@rc-component/dropdown": ["@rc-component/dropdown@1.0.2", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg=="], + + "@rc-component/form": ["@rc-component/form@1.8.1", "", { "dependencies": { "@rc-component/async-validator": "^5.1.0", "@rc-component/util": "^1.6.2", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8O7TB55Fi2mWIGvSnwZjk8jFqVNYyKDAswglwGShcbndxqzKz4cHwNtNaLjZlAeRge9wcB0LL8IWsC/Bl18raQ=="], + + "@rc-component/image": ["@rc-component/image@1.9.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ=="], + + "@rc-component/input": ["@rc-component/input@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg=="], + + "@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="], + + "@rc-component/mentions": ["@rc-component/mentions@1.6.0", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/menu": "~1.2.0", "@rc-component/textarea": "~1.1.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ=="], + + "@rc-component/menu": ["@rc-component/menu@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg=="], + + "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw=="], + + "@rc-component/motion": ["@rc-component/motion@1.3.2", "", { "dependencies": { "@rc-component/util": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ=="], + + "@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="], + + "@rc-component/notification": ["@rc-component/notification@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA=="], + + "@rc-component/overflow": ["@rc-component/overflow@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA=="], + + "@rc-component/pagination": ["@rc-component/pagination@1.2.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw=="], + + "@rc-component/picker": ["@rc-component/picker@1.9.1", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g=="], + + "@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="], + + "@rc-component/progress": ["@rc-component/progress@1.0.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ=="], + + "@rc-component/qrcode": ["@rc-component/qrcode@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA=="], + + "@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="], + + "@rc-component/resize-observer": ["@rc-component/resize-observer@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q=="], + + "@rc-component/segmented": ["@rc-component/segmented@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg=="], + + "@rc-component/select": ["@rc-component/select@1.6.15", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g=="], + + "@rc-component/slider": ["@rc-component/slider@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g=="], + + "@rc-component/steps": ["@rc-component/steps@1.2.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw=="], + + "@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="], + + "@rc-component/table": ["@rc-component/table@1.9.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.1.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg=="], + + "@rc-component/tabs": ["@rc-component/tabs@1.7.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w=="], + + "@rc-component/textarea": ["@rc-component/textarea@1.1.2", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A=="], + + "@rc-component/tooltip": ["@rc-component/tooltip@1.4.0", "", { "dependencies": { "@rc-component/trigger": "^3.7.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg=="], + + "@rc-component/tour": ["@rc-component/tour@2.3.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow=="], + + "@rc-component/tree": ["@rc-component/tree@1.2.4", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.8.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w=="], + + "@rc-component/tree-select": ["@rc-component/tree-select@1.8.0", "", { "dependencies": { "@rc-component/select": "~1.6.0", "@rc-component/tree": "~1.2.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ=="], + + "@rc-component/trigger": ["@rc-component/trigger@3.9.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.2.0", "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg=="], + + "@rc-component/upload": ["@rc-component/upload@1.1.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw=="], + + "@rc-component/util": ["@rc-component/util@1.10.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng=="], + + "@rc-component/virtual-list": ["@rc-component/virtual-list@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "https://registry.npmmirror.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], @@ -495,13 +691,21 @@ "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + "@shikijs/core": ["@shikijs/core@4.0.2", "", { "dependencies": { "@shikijs/primitive": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw=="], - "@shikijs/langs": ["@shikijs/langs@3.23.0", "https://registry.npmmirror.com/@shikijs/langs/-/langs-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag=="], - "@shikijs/themes": ["@shikijs/themes@3.23.0", "https://registry.npmmirror.com/@shikijs/themes/-/themes-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg=="], - "@shikijs/types": ["@shikijs/types@3.23.0", "https://registry.npmmirror.com/@shikijs/types/-/types-3.23.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@shikijs/langs": ["@shikijs/langs@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw=="], + + "@shikijs/themes": ["@shikijs/themes@4.0.2", "", { "dependencies": { "@shikijs/types": "4.0.2" } }, "sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA=="], + + "@shikijs/transformers": ["@shikijs/transformers@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/types": "4.0.2" } }, "sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg=="], + + "@shikijs/types": ["@shikijs/types@4.0.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg=="], "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], @@ -509,10 +713,14 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "https://registry.npmmirror.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], + "@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "https://registry.npmmirror.com/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.4", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.4.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="], @@ -543,6 +751,10 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.4", "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.4", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", "tailwindcss": "4.2.4" } }, "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg=="], + + "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "https://registry.npmmirror.com/@tailwindcss/typography/-/typography-0.5.19.tgz", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.4", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.4.tgz", { "dependencies": { "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw=="], "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.3", "https://registry.npmmirror.com/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", { "bin": { "intent": "bin/intent.js" } }, "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw=="], @@ -573,12 +785,16 @@ "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.24", "", { "dependencies": { "@tanstack/virtual-core": "3.14.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg=="], + "@tanstack/router-core": ["@tanstack/router-core@1.169.1", "https://registry.npmmirror.com/@tanstack/router-core/-/router-core-1.169.1.tgz", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^3.0.0", "seroval": "^1.5.0", "seroval-plugins": "^1.5.0" }, "bin": { "intent": "bin/intent.js" } }, "sha512-x+2gIGKTTE1qAn7tLieGfrB5ciOviDmmi2ox9fAWUubRV+yTU5ruGFXocoCIWF+lB+SOtnHjo2E9BLSWyYoEmA=="], "@tanstack/store": ["@tanstack/store@0.11.0", "https://registry.npmmirror.com/@tanstack/store/-/store-0.11.0.tgz", {}, "sha512-WlzzCt3xi0G6pCAJu1U+2jiECwabETDpQDi3hfkFZvJii9AuZqEKbOiVarX1/bWhTNjU486yQtJCCasi/0q+Cw=="], "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.14.0", "", {}, "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q=="], + "@transloadit/prettier-bytes": ["@transloadit/prettier-bytes@0.3.5", "https://registry.npmmirror.com/@transloadit/prettier-bytes/-/prettier-bytes-0.3.5.tgz", {}, "sha512-xF4A3d/ZyX2LJWeQZREZQw+qFX4TGQ8bGVP97OLRt6sPO6T0TNHBFTuRHOJh7RNmYOBmQ9MHxpolD9bXihpuVA=="], "@transloadit/types": ["@transloadit/types@4.3.0", "https://registry.npmmirror.com/@transloadit/types/-/types-4.3.0.tgz", {}, "sha512-kWuHGp4YhWe0upPDIsoMd6LF6UPOgfelOxN0OzDrztILqRqBPShJqe47Dp7InokVTZIwl9J1t32RAPb9oq4iWw=="], @@ -595,32 +811,94 @@ "@types/cors": ["@types/cors@2.8.19", "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmmirror.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmmirror.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "https://registry.npmmirror.com/@types/d3-ease/-/d3-ease-3.0.2.tgz", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "https://registry.npmmirror.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], "@types/d3-path": ["@types/d3-path@3.1.1", "https://registry.npmmirror.com/@types/d3-path/-/d3-path-3.1.1.tgz", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + "@types/d3-scale": ["@types/d3-scale@4.0.9", "https://registry.npmmirror.com/@types/d3-scale/-/d3-scale-4.0.9.tgz", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + "@types/d3-shape": ["@types/d3-shape@3.1.8", "https://registry.npmmirror.com/@types/d3-shape/-/d3-shape-3.1.8.tgz", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], "@types/d3-time": ["@types/d3-time@3.0.4", "https://registry.npmmirror.com/@types/d3-time/-/d3-time-3.0.4.tgz", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + "@types/d3-timer": ["@types/d3-timer@3.0.2", "https://registry.npmmirror.com/@types/d3-timer/-/d3-timer-3.0.2.tgz", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + "@types/estree": ["@types/estree@1.0.8", "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "https://registry.npmmirror.com/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + + "@types/mdast": ["@types/mdast@4.0.4", "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + + "@types/ms": ["@types/ms@2.1.0", "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@24.12.2", "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="], + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + "@types/react": ["@types/react@19.2.14", "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -631,6 +909,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "https://registry.npmmirror.com/@types/statuses/-/statuses-2.0.6.tgz", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/unist": ["@types/unist@3.0.3", "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], @@ -659,6 +939,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.1", "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz", { "dependencies": { "@typescript-eslint/types": "8.59.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg=="], + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + "@uppy/audio": ["@uppy/audio@3.1.0", "https://registry.npmmirror.com/@uppy/audio/-/audio-3.1.0.tgz", { "dependencies": { "@uppy/utils": "^7.1.4", "preact": "^10.5.13" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-149eswgMyLA9zQzIvgDzwtA0O2cWV6UUR1ke4vNDyKC8/9Litg1m8iXjri5bmWVVjaAgNpADmucKPt3vL8a/jg=="], "@uppy/aws-s3": ["@uppy/aws-s3@5.1.0", "https://registry.npmmirror.com/@uppy/aws-s3/-/aws-s3-5.1.0.tgz", { "dependencies": { "@uppy/companion-client": "^5.1.1", "@uppy/utils": "^7.1.4" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-UBz+shrtDbnOf11AboDrkc9Fq2Cdf8HbFftE+gqfxig6fkv5rHpHhBCLkl8wCGAq+X/CxdqvvNhm/OM23Uzw2w=="], @@ -731,6 +1013,12 @@ "@uppy/zoom": ["@uppy/zoom@4.1.0", "https://registry.npmmirror.com/@uppy/zoom/-/zoom-4.1.0.tgz", { "dependencies": { "@uppy/companion-client": "^5.1.1", "@uppy/provider-views": "^5.2.0", "@uppy/utils": "^7.1.4", "preact": "^10.5.13" }, "peerDependencies": { "@uppy/core": "^5.2.0" } }, "sha512-roW/Zz0n8WDxSIg5rOO2Kp0rapE2z/U4ZVQdRFYhM3MMMQV0YW6CiUWgWOU4jtsdvAqsNisdEmbPAgSRSZK3ug=="], + "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], + + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], "accepts": ["accepts@1.3.8", "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], @@ -741,6 +1029,8 @@ "agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ahooks": ["ahooks@3.9.7", "", { "dependencies": { "@babel/runtime": "^7.21.0", "@types/js-cookie": "^3.0.6", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-S0lvzhbdlhK36RFBkGv+RbOM/dbbweym+BIHM/bwwuWVSVN5TuVErHPMWo4w0t1NDYg5KPp2iEf7Y7E5LASYiw=="], + "ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "https://registry.npmmirror.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], @@ -755,16 +1045,30 @@ "ansi-styles": ["ansi-styles@4.3.0", "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "antd": ["antd@6.3.7", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.1.2", "@ant-design/cssinjs-utils": "^2.1.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.1", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.14.0", "@rc-component/checkbox": "~2.0.0", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.1.1", "@rc-component/dialog": "~1.8.4", "@rc-component/drawer": "~1.4.2", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.8.1", "@rc-component/image": "~1.9.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.3.2", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.1", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.2", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.6.15", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.2.4", "@rc-component/tree-select": "~1.8.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-WTHi4bHVNKpYXLHESzU0Tts7rRNQeL84Bph9dfI3Qw7mHbTulExDcYKNHny5CTXcrBBOpraXbU9miBAwUR5vaw=="], + + "antd-style": ["antd-style@4.1.0", "", { "dependencies": { "@ant-design/cssinjs": "^2.0.0", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=6.0.0", "react": ">=18" } }, "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ=="], + "argparse": ["argparse@2.0.1", "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-hidden": ["aria-hidden@1.2.6", "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], + "ast-types": ["ast-types@0.16.1", "https://registry.npmmirror.com/ast-types/-/ast-types-0.16.1.tgz", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + "asynckit": ["asynckit@0.4.0", "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + "axios": ["axios@1.15.2", "https://registry.npmmirror.com/axios/-/axios-1.15.2.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + + "bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "balanced-match": ["balanced-match@1.0.2", "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "base64id": ["base64id@2.0.0", "https://registry.npmmirror.com/base64id/-/base64id-2.0.0.tgz", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="], @@ -795,12 +1099,26 @@ "caniuse-lite": ["caniuse-lite@1.0.30001791", "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="], - "casl": ["casl@1.1.0", "https://registry.npmmirror.com/casl/-/casl-1.1.0.tgz", { "dependencies": { "sift": "^5.0.0" } }, "sha512-58M7lfbzIDLt/y/f4ierhdFyv56YTtziJGAaObwzkEDutx5ycORBhdJY3V/desmOduUn2/5EKksbDCf260uQvw=="], + "ccount": ["ccount@2.0.1", "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "chalk": ["chalk@4.1.2", "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "character-entities": ["character-entities@2.0.2", "https://registry.npmmirror.com/character-entities/-/character-entities-2.0.2.tgz", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "https://registry.npmmirror.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chevrotain": ["chevrotain@12.0.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", "@chevrotain/gast": "12.0.0", "@chevrotain/regexp-to-ast": "12.0.0", "@chevrotain/types": "12.0.0", "@chevrotain/utils": "12.0.0" } }, "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.4.3", "", { "dependencies": { "lodash-es": "^4.18.1" }, "peerDependencies": { "chevrotain": "^12.0.0" } }, "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ=="], + "chokidar": ["chokidar@5.0.0", "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chroma-js": ["chroma-js@3.2.0", "", {}, "sha512-os/OippSlX1RlWWr+QDPcGUZs0uoqr32urfxESG9U93lhUfbnlyckte84Q8P1UQY/qth983AS1JONKmLS4T0nw=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "classnames": ["classnames@2.5.1", "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], @@ -819,14 +1137,20 @@ "code-block-writer": ["code-block-writer@13.0.3", "https://registry.npmmirror.com/code-block-writer/-/code-block-writer-13.0.3.tgz", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + "color-convert": ["color-convert@2.0.1", "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + "combine-errors": ["combine-errors@3.0.3", "https://registry.npmmirror.com/combine-errors/-/combine-errors-3.0.3.tgz", { "dependencies": { "custom-error-instance": "2.1.1", "lodash.uniqby": "4.5.0" } }, "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q=="], "combined-stream": ["combined-stream@1.0.8", "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "commander": ["commander@14.0.3", "https://registry.npmmirror.com/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "compare-versions": ["compare-versions@6.1.1", "https://registry.npmmirror.com/compare-versions/-/compare-versions-6.1.1.tgz", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], @@ -835,6 +1159,8 @@ "compressorjs": ["compressorjs@1.3.0", "https://registry.npmmirror.com/compressorjs/-/compressorjs-1.3.0.tgz", { "dependencies": { "blueimp-canvas-to-blob": "^3.29.0", "is-blob": "^2.1.0" } }, "sha512-TsvzkRgDm/6mIRUdxJbrTH7kfSW3oJzOw8b1xU60fziQSosTML5TczpO6Z4H1LGF0yRmTotk6r5UNhuRxEwA1A=="], + "compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], + "concat-map": ["concat-map@0.0.1", "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -851,6 +1177,8 @@ "cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + "cosmiconfig": ["cosmiconfig@9.0.1", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], "cropperjs": ["cropperjs@1.6.2", "https://registry.npmmirror.com/cropperjs/-/cropperjs-1.6.2.tgz", {}, "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA=="], @@ -863,20 +1191,64 @@ "custom-error-instance": ["custom-error-instance@2.1.1", "https://registry.npmmirror.com/custom-error-instance/-/custom-error-instance-2.1.1.tgz", {}, "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg=="], + "cytoscape": ["cytoscape@3.33.3", "", {}, "sha512-Gej7U+OKR+LZ8kvX7rb2HhCYJ0IhvEFsnkud4SB1PR+BUY/TsSO0dmOW59WEVLu51b1Rm+gQRKoz4bLYxGSZ2g=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + "d3-array": ["d3-array@3.2.4", "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + "d3-color": ["d3-color@3.1.0", "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + "d3-ease": ["d3-ease@3.0.1", "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + "d3-format": ["d3-format@3.1.2", "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + "d3-interpolate": ["d3-interpolate@3.0.1", "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], "d3-path": ["d3-path@3.1.0", "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + "d3-scale": ["d3-scale@4.0.2", "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + "d3-shape": ["d3-shape@3.2.0", "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], "d3-time": ["d3-time@3.1.0", "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], @@ -885,6 +1257,12 @@ "d3-timer": ["d3-timer@3.0.1", "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "date-fns": ["date-fns@4.1.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], @@ -897,6 +1275,10 @@ "decimal.js-light": ["decimal.js-light@2.5.1", "https://registry.npmmirror.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="], + "dedent": ["dedent@1.7.2", "https://registry.npmmirror.com/dedent/-/dedent-1.7.2.tgz", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], "deep-is": ["deep-is@0.1.4", "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -909,18 +1291,30 @@ "define-lazy-prop": ["define-lazy-prop@3.0.0", "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], "depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "devlop": ["devlop@1.1.0", "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dexie": ["dexie@4.4.2", "", {}, "sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw=="], + + "dexie-react-hooks": ["dexie-react-hooks@4.4.0", "", { "peerDependencies": { "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA=="], + "diff": ["diff@8.0.4", "https://registry.npmmirror.com/diff/-/diff-8.0.4.tgz", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "dnd-kit": ["dnd-kit@0.0.2", "https://registry.npmmirror.com/dnd-kit/-/dnd-kit-0.0.2.tgz", {}, "sha512-d8AYd6I7D2b5u882+QNVGw0slBAt851/LWZ2j/pU+onf5/TGEKXeb47sCyhPYKEAUXp4oLfvWfNCqfkU03R1lw=="], + "dompurify": ["dompurify@3.4.2", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA=="], + "dotenv": ["dotenv@17.4.2", "https://registry.npmmirror.com/dotenv/-/dotenv-17.4.2.tgz", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -937,6 +1331,8 @@ "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "https://registry.npmmirror.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], + "emoji-regex": ["emoji-regex@10.6.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -967,6 +1363,10 @@ "es-toolkit": ["es-toolkit@1.46.1", "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.46.1.tgz", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="], + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + "esbuild": ["esbuild@0.27.7", "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -995,6 +1395,20 @@ "estraverse": ["estraverse@5.3.0", "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "https://registry.npmmirror.com/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], @@ -1013,6 +1427,10 @@ "express-rate-limit": ["express-rate-limit@8.4.1", "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.4.1.tgz", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw=="], + "extend": ["extend@3.0.2", "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -1039,10 +1457,16 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], + "fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], + "finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + "find-up": ["find-up@5.0.0", "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -1051,6 +1475,8 @@ "follow-redirects": ["follow-redirects@1.16.0", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], + "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], + "form-data": ["form-data@4.0.5", "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], @@ -1087,6 +1513,10 @@ "get-stream": ["get-stream@9.0.1", "https://registry.npmmirror.com/get-stream/-/get-stream-9.0.1.tgz", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], + + "giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], + "glob-parent": ["glob-parent@6.0.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "https://registry.npmmirror.com/globals/-/globals-16.5.0.tgz", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -1099,6 +1529,8 @@ "graphql": ["graphql@16.13.2", "https://registry.npmmirror.com/graphql/-/graphql-16.13.2.tgz", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "has-flag": ["has-flag@4.0.0", "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -1107,16 +1539,50 @@ "hasown": ["hasown@2.0.3", "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "https://registry.npmmirror.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + "headers-polyfill": ["headers-polyfill@5.0.1", "https://registry.npmmirror.com/headers-polyfill/-/headers-polyfill-5.0.1.tgz", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="], "hermes-estree": ["hermes-estree@0.25.1", "https://registry.npmmirror.com/hermes-estree/-/hermes-estree-0.25.1.tgz", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], "hermes-parser": ["hermes-parser@0.25.1", "https://registry.npmmirror.com/hermes-parser/-/hermes-parser-0.25.1.tgz", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "hono": ["hono@4.12.16", "https://registry.npmmirror.com/hono/-/hono-4.12.16.tgz", {}, "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg=="], "html-parse-stringify": ["html-parse-stringify@3.0.1", "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "html-url-attributes": ["html-url-attributes@3.0.1", "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "http-errors": ["http-errors@2.0.1", "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1135,30 +1601,48 @@ "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], "inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.2.7.tgz", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "input-otp": ["input-otp@1.4.2", "https://registry.npmmirror.com/input-otp/-/input-otp-1.4.2.tgz", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="], "internmap": ["internmap@2.0.3", "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="], + "ip-address": ["ip-address@10.1.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.1.0.tgz", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "https://registry.npmmirror.com/is-alphabetical/-/is-alphabetical-2.0.1.tgz", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "https://registry.npmmirror.com/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + "is-arrayish": ["is-arrayish@0.2.1", "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-blob": ["is-blob@2.1.0", "https://registry.npmmirror.com/is-blob/-/is-blob-2.1.0.tgz", {}, "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-decimal": ["is-decimal@2.0.1", "https://registry.npmmirror.com/is-decimal/-/is-decimal-2.0.1.tgz", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], + "is-extglob": ["is-extglob@2.1.1", "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "https://registry.npmmirror.com/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "https://registry.npmmirror.com/is-in-ssh/-/is-in-ssh-1.0.0.tgz", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -1179,6 +1663,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "https://registry.npmmirror.com/is-regexp/-/is-regexp-3.1.0.tgz", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1193,12 +1679,16 @@ "isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + "jiti": ["jiti@2.6.1", "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], "js-base64": ["js-base64@3.7.8", "https://registry.npmmirror.com/js-base64/-/js-base64-3.7.8.tgz", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + "js-tokens": ["js-tokens@4.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -1217,16 +1707,28 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="], + "json5": ["json5@2.2.3", "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.2.1", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], "jsonpointer": ["jsonpointer@5.0.1", "https://registry.npmmirror.com/jsonpointer/-/jsonpointer-5.0.1.tgz", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "katex": ["katex@0.16.45", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], + "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + "kleur": ["kleur@4.1.5", "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "langium": ["langium@4.2.3", "", { "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", "chevrotain": "~12.0.0", "chevrotain-allstar": "~0.4.3", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "leva": ["leva@0.10.1", "", { "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA=="], + "leven": ["leven@4.1.0", "https://registry.npmmirror.com/leven/-/leven-4.1.0.tgz", {}, "sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew=="], "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -1259,10 +1761,18 @@ "linkify-it": ["linkify-it@5.0.0", "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lit": ["lit@3.3.2", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="], + "locate-path": ["locate-path@6.0.0", "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash": ["lodash@4.18.1", "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash._baseiteratee": ["lodash._baseiteratee@4.7.0", "https://registry.npmmirror.com/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", { "dependencies": { "lodash._stringtopath": "~4.8.0" } }, "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ=="], "lodash._basetostring": ["lodash._basetostring@4.12.0", "https://registry.npmmirror.com/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", {}, "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw=="], @@ -1283,18 +1793,66 @@ "log-symbols": ["log-symbols@6.0.0", "https://registry.npmmirror.com/log-symbols/-/log-symbols-6.0.0.tgz", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + "longest-streak": ["longest-streak@3.1.0", "https://registry.npmmirror.com/longest-streak/-/longest-streak-3.1.0.tgz", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], + "lucide-react": ["lucide-react@1.14.0", "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.14.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], "lunr": ["lunr@2.3.9", "https://registry.npmmirror.com/lunr/-/lunr-2.3.9.tgz", {}, "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow=="], "magic-string": ["magic-string@0.30.21", "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + "markdown-it": ["markdown-it@14.1.1", "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.1.tgz", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + "markdown-table": ["markdown-table@3.0.4", "https://registry.npmmirror.com/markdown-table/-/markdown-table-3.0.4.tgz", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "https://registry.npmmirror.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "https://registry.npmmirror.com/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "https://registry.npmmirror.com/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "https://registry.npmmirror.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "https://registry.npmmirror.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "https://registry.npmmirror.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "https://registry.npmmirror.com/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "https://registry.npmmirror.com/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "https://registry.npmmirror.com/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "https://registry.npmmirror.com/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "https://registry.npmmirror.com/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "https://registry.npmmirror.com/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdurl": ["mdurl@2.0.0", "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], @@ -1303,8 +1861,88 @@ "merge-stream": ["merge-stream@2.0.0", "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge-value": ["merge-value@1.0.0", "", { "dependencies": { "get-value": "^2.0.6", "is-extendable": "^1.0.0", "mixin-deep": "^1.2.0", "set-value": "^2.0.0" } }, "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ=="], + "merge2": ["merge2@1.4.1", "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + "mermaid": ["mermaid@11.14.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g=="], + + "micromark": ["micromark@4.0.2", "https://registry.npmmirror.com/micromark/-/micromark-4.0.2.tgz", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "https://registry.npmmirror.com/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="], + + "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "https://registry.npmmirror.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "https://registry.npmmirror.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "https://registry.npmmirror.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "https://registry.npmmirror.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "https://registry.npmmirror.com/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "https://registry.npmmirror.com/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "https://registry.npmmirror.com/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "https://registry.npmmirror.com/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "https://registry.npmmirror.com/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "https://registry.npmmirror.com/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "https://registry.npmmirror.com/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "https://registry.npmmirror.com/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "https://registry.npmmirror.com/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "https://registry.npmmirror.com/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "https://registry.npmmirror.com/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "https://registry.npmmirror.com/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "https://registry.npmmirror.com/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "https://registry.npmmirror.com/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + "micromatch": ["micromatch@4.0.8", "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "mime-db": ["mime-db@1.52.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -1321,6 +1959,8 @@ "minimist": ["minimist@1.2.8", "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], + "motion": ["motion@12.38.0", "https://registry.npmmirror.com/motion/-/motion-12.38.0.tgz", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="], "motion-dom": ["motion-dom@12.38.0", "https://registry.npmmirror.com/motion-dom/-/motion-dom-12.38.0.tgz", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], @@ -1351,18 +1991,26 @@ "npm-run-path": ["npm-run-path@6.0.0", "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-6.0.0.tgz", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="], + "object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-treeify": ["object-treeify@1.1.33", "https://registry.npmmirror.com/object-treeify/-/object-treeify-1.1.33.tgz", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + "on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="], + "on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@5.1.2", "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + "open": ["open@11.0.0", "https://registry.npmmirror.com/open/-/open-11.0.0.tgz", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "optionator": ["optionator@0.9.4", "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -1383,22 +2031,34 @@ "p-timeout": ["p-timeout@6.1.4", "https://registry.npmmirror.com/p-timeout/-/p-timeout-6.1.4.tgz", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "parse-json": ["parse-json@5.2.0", "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], "parse-ms": ["parse-ms@4.0.0", "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-browserify": ["path-browserify@1.0.1", "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + "path-exists": ["path-exists@4.0.0", "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-6.3.0.tgz", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1407,9 +2067,15 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], + "postcss": ["postcss@8.5.13", "https://registry.npmmirror.com/postcss/-/postcss-8.5.13.tgz", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag=="], - "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "powershell-utils": ["powershell-utils@0.1.0", "https://registry.npmmirror.com/powershell-utils/-/powershell-utils-0.1.0.tgz", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], @@ -1427,8 +2093,12 @@ "prompts": ["prompts@2.4.2", "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "https://registry.npmmirror.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "proxy-from-env": ["proxy-from-env@2.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], @@ -1439,6 +2109,8 @@ "qs": ["qs@6.15.1", "https://registry.npmmirror.com/qs/-/qs-6.15.1.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + "query-string": ["query-string@9.3.1", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw=="], + "querystringify": ["querystringify@2.2.0", "https://registry.npmmirror.com/querystringify/-/querystringify-2.2.0.tgz", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="], "queue-microtask": ["queue-microtask@1.2.3", "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1451,18 +2123,60 @@ "raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], + + "rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="], + + "rc-footer": ["rc-footer@0.6.8", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-JBZ+xcb6kkex8XnBd4VHw1ZxjV6kmcwUumSHaIFdka2qzMCo7Klcy4sI6G0XtUpG/vtpislQCc+S9Bc+NLHYMg=="], + + "rc-image": ["rc-image@7.12.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", "classnames": "^2.2.6", "rc-dialog": "~9.6.0", "rc-motion": "^2.6.2", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q=="], + + "rc-input": ["rc-input@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.18.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA=="], + + "rc-input-number": ["rc-input-number@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag=="], + + "rc-menu": ["rc-menu@9.16.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", "classnames": "2.x", "rc-motion": "^2.4.3", "rc-overflow": "^1.3.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg=="], + + "rc-motion": ["rc-motion@2.9.5", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA=="], + + "rc-overflow": ["rc-overflow@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-resize-observer": "^1.0.0", "rc-util": "^5.37.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg=="], + + "rc-resize-observer": ["rc-resize-observer@1.4.3", "", { "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", "rc-util": "^5.44.1", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ=="], + + "rc-util": ["rc-util@5.44.4", "", { "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w=="], + + "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="], + "react": ["react@19.2.5", "https://registry.npmmirror.com/react/-/react-19.2.5.tgz", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], + "react-avatar-editor": ["react-avatar-editor@15.1.0", "", { "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Zto7u9l6Wd5LPPtjeFJ+7uwoT4bs01OSgkN2kxD18lWl8IiZ0GY3nWCbKPx4qIU7Au1vENsMJm19rfVWHHayaQ=="], + + "react-colorful": ["react-colorful@5.6.2", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-7Vankf05ygS7v4T1gJPxqNIJZcsZ46K71J3fF995cfYOMFskAkFYUXna+90bwK/dr/1zVqrJQorNuc9OTV/qXA=="], + "react-day-picker": ["react-day-picker@9.14.0", "https://registry.npmmirror.com/react-day-picker/-/react-day-picker-9.14.0.tgz", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], "react-dom": ["react-dom@19.2.5", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], + "react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="], + + "react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], + + "react-error-boundary": ["react-error-boundary@6.1.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w=="], + + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + "react-hook-form": ["react-hook-form@7.75.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.75.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], + "react-hotkeys-hook": ["react-hotkeys-hook@5.3.2", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw=="], + "react-i18next": ["react-i18next@17.0.6", "https://registry.npmmirror.com/react-i18next/-/react-i18next-17.0.6.tgz", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw=="], "react-is": ["react-is@19.2.5", "https://registry.npmmirror.com/react-is/-/react-is-19.2.5.tgz", {}, "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ=="], + "react-markdown": ["react-markdown@10.1.0", "https://registry.npmmirror.com/react-markdown/-/react-markdown-10.1.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-merge-refs": ["react-merge-refs@3.0.2", "", { "peerDependencies": { "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw=="], + "react-redux": ["react-redux@9.2.0", "https://registry.npmmirror.com/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], "react-refresh": ["react-refresh@0.18.0", "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.18.0.tgz", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], @@ -1473,24 +2187,72 @@ "react-resizable-panels": ["react-resizable-panels@4.10.0", "https://registry.npmmirror.com/react-resizable-panels/-/react-resizable-panels-4.10.0.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-frjewRQt7TCv/vCH1pJfjZ7RxAhr5pKuqVQtVgzFq/vherxBFOWyC3xMbryx5Ti2wylViGUFc93Etg4rB3E0UA=="], + "react-rnd": ["react-rnd@10.5.3", "", { "dependencies": { "re-resizable": "^6.11.2", "react-draggable": "^4.5.0", "tslib": "2.6.2" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q=="], + "react-router": ["react-router@7.14.2", "https://registry.npmmirror.com/react-router/-/react-router-7.14.2.tgz", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw=="], "react-router-dom": ["react-router-dom@7.14.2", "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.14.2.tgz", { "dependencies": { "react-router": "7.14.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-virtuoso": ["react-virtuoso@4.18.6", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw=="], + + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], + "readdirp": ["readdirp@5.0.0", "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "recast": ["recast@0.23.11", "https://registry.npmmirror.com/recast/-/recast-0.23.11.tgz", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], "recharts": ["recharts@3.8.1", "https://registry.npmmirror.com/recharts/-/recharts-3.8.1.tgz", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + "redux": ["redux@5.0.1", "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], "redux-thunk": ["redux-thunk@3.1.0", "https://registry.npmmirror.com/redux-thunk/-/redux-thunk-3.1.0.tgz", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-github-alerts": ["rehype-github-alerts@4.2.0", "", { "dependencies": { "@primer/octicons": "^19.20.0", "hast-util-from-html": "^2.0.3", "hast-util-is-element": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-6di6kEu9WUHKLKrkKG2xX6AOuaCMGghg0Wq7MEuM/jBYUPVIq6PJpMe00dxMfU+/YSBtDXhffpDimgDi+BObIQ=="], + + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-cjk-friendly": ["remark-cjk-friendly@2.0.1", "", { "dependencies": { "micromark-extension-cjk-friendly": "2.0.1" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-6WwkoQyZf/4j5k53zdFYrR8Ca+UVn992jXdLUSBDZR4eBpFhKyVxmA4gUHra/5fesjGIxrDhHesNr/sVoiiysA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "https://registry.npmmirror.com/remark-gfm/-/remark-gfm-4.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-github": ["remark-github@12.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0", "mdast-util-to-string": "^4.0.0", "to-vfile": "^8.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg=="], + + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "https://registry.npmmirror.com/remark-parse/-/remark-parse-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "https://registry.npmmirror.com/remark-rehype/-/remark-rehype-11.1.2.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "https://registry.npmmirror.com/remark-stringify/-/remark-stringify-11.0.0.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "remeda": ["remeda@2.34.0", "https://registry.npmmirror.com/remeda/-/remeda-2.34.0.tgz", {}, "sha512-zL4cEPkLHxwmlDRPyvJZjojpG5M5HXrDiABNKof+dq7kkuyQttP6NrF2uJB0DKIU09K8cTq+sQDlbo2r7mdR5Q=="], + "remend": ["remend@1.3.0", "", {}, "sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw=="], + "require-directory": ["require-directory@2.1.1", "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1499,6 +2261,10 @@ "reselect": ["reselect@5.1.1", "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "resolve-from": ["resolve-from@4.0.0", "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "restore-cursor": ["restore-cursor@5.1.0", "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-5.1.0.tgz", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -1509,20 +2275,32 @@ "reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rollup": ["rollup@4.60.2", "https://registry.npmmirror.com/rollup/-/rollup-4.60.2.tgz", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.2", "@rollup/rollup-android-arm64": "4.60.2", "@rollup/rollup-darwin-arm64": "4.60.2", "@rollup/rollup-darwin-x64": "4.60.2", "@rollup/rollup-freebsd-arm64": "4.60.2", "@rollup/rollup-freebsd-x64": "4.60.2", "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", "@rollup/rollup-linux-arm-musleabihf": "4.60.2", "@rollup/rollup-linux-arm64-gnu": "4.60.2", "@rollup/rollup-linux-arm64-musl": "4.60.2", "@rollup/rollup-linux-loong64-gnu": "4.60.2", "@rollup/rollup-linux-loong64-musl": "4.60.2", "@rollup/rollup-linux-ppc64-gnu": "4.60.2", "@rollup/rollup-linux-ppc64-musl": "4.60.2", "@rollup/rollup-linux-riscv64-gnu": "4.60.2", "@rollup/rollup-linux-riscv64-musl": "4.60.2", "@rollup/rollup-linux-s390x-gnu": "4.60.2", "@rollup/rollup-linux-x64-gnu": "4.60.2", "@rollup/rollup-linux-x64-musl": "4.60.2", "@rollup/rollup-openbsd-x64": "4.60.2", "@rollup/rollup-openharmony-arm64": "4.60.2", "@rollup/rollup-win32-arm64-msvc": "4.60.2", "@rollup/rollup-win32-ia32-msvc": "4.60.2", "@rollup/rollup-win32-x64-gnu": "4.60.2", "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ=="], + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + "router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], "run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], + + "scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], + "semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + "send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "seroval": ["seroval@1.5.2", "https://registry.npmmirror.com/seroval/-/seroval-1.5.2.tgz", {}, "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q=="], @@ -1533,6 +2311,8 @@ "set-cookie-parser": ["set-cookie-parser@2.7.2", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], + "set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="], + "setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shadcn": ["shadcn@4.6.0", "https://registry.npmmirror.com/shadcn/-/shadcn-4.6.0.tgz", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-4XeMwFf8ZZxmqQQp+U+Nsq2M+cY4Da8Joo/EaMdHVc4uVuWSTJoeidlZ3gDjyxXCjYB1FLcxYwR4lYQAH8emOg=="], @@ -1543,6 +2323,10 @@ "shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="], + + "shiki-stream": ["shiki-stream@0.1.4", "", { "dependencies": { "@shikijs/core": "^3.0.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-4pz6JGSDmVTTkPJ/ueixHkFAXY4ySCc+unvCaDZV7hqq/sdJZirRxgIXSuNSKgiFlGTgRR97sdu2R8K55sPsrw=="], + "side-channel": ["side-channel@1.1.0", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -1551,8 +2335,6 @@ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - "sift": ["sift@5.1.0", "https://registry.npmmirror.com/sift/-/sift-5.1.0.tgz", {}, "sha512-tQOVb0Z8pJZ50QBaLI/yRxGI4paVinEHcBxe2rbV3Im8bbsa47xj6luWPw2ekGwhrsuyzECbQyu1pltAlfsz9g=="], - "signal-exit": ["signal-exit@4.1.0", "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1573,6 +2355,12 @@ "source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "space-separated-tokens": ["space-separated-tokens@2.0.2", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="], + + "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], + "statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "stdin-discarder": ["stdin-discarder@0.2.2", "https://registry.npmmirror.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], @@ -1581,8 +2369,12 @@ "string-argv": ["string-argv@0.3.2", "https://registry.npmmirror.com/string-argv/-/string-argv-0.3.2.tgz", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], + "string-width": ["string-width@7.2.0", "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "stringify-object": ["stringify-object@5.0.0", "https://registry.npmmirror.com/stringify-object/-/stringify-object-5.0.0.tgz", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1593,8 +2385,20 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "style-to-js": ["style-to-js@1.1.21", "https://registry.npmmirror.com/style-to-js/-/style-to-js-1.1.21.tgz", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "https://registry.npmmirror.com/style-to-object/-/style-to-object-1.0.14.tgz", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], + "supports-color": ["supports-color@7.2.0", "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + "tagged-tag": ["tagged-tag@1.0.0", "https://registry.npmmirror.com/tagged-tag/-/tagged-tag-1.0.0.tgz", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tailwind-merge": ["tailwind-merge@3.5.0", "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], @@ -1603,8 +2407,12 @@ "tapable": ["tapable@2.3.3", "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + "tinyglobby": ["tinyglobby@0.2.16", "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tldts": ["tldts@7.0.30", "https://registry.npmmirror.com/tldts/-/tldts-7.0.30.tgz", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], @@ -1613,12 +2421,22 @@ "to-regex-range": ["to-regex-range@5.0.1", "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "to-vfile": ["to-vfile@8.0.0", "", { "dependencies": { "vfile": "^6.0.0" } }, "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg=="], + "toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], "tough-cookie": ["tough-cookie@6.0.1", "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-6.0.1.tgz", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "trim-lines": ["trim-lines@3.0.1", "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "https://registry.npmmirror.com/trough/-/trough-2.2.0.tgz", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-api-utils": ["ts-api-utils@2.5.0", "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + + "ts-md5": ["ts-md5@2.0.1", "", {}, "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w=="], + "ts-morph": ["ts-morph@26.0.0", "https://registry.npmmirror.com/ts-morph/-/ts-morph-26.0.0.tgz", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], "tsconfck": ["tsconfck@3.1.6", "https://registry.npmmirror.com/tsconfck/-/tsconfck-3.1.6.tgz", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -1653,6 +2471,24 @@ "unicorn-magic": ["unicorn-magic@0.3.0", "https://registry.npmmirror.com/unicorn-magic/-/unicorn-magic-0.3.0.tgz", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + "unified": ["unified@11.0.5", "https://registry.npmmirror.com/unified/-/unified-11.0.5.tgz", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.1.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.1.0.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + "universalify": ["universalify@2.0.1", "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], @@ -1665,28 +2501,58 @@ "uri-js": ["uri-js@4.4.1", "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], + "url-parse": ["url-parse@1.5.10", "https://registry.npmmirror.com/url-parse/-/url-parse-1.5.10.tgz", { "dependencies": { "querystringify": "^2.1.1", "requires-port": "^1.0.0" } }, "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ=="], "use-callback-ref": ["use-callback-ref@1.3.3", "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + "use-merge-value": ["use-merge-value@1.2.0", "", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="], + "use-sidecar": ["use-sidecar@1.1.3", "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sync-external-store": ["use-sync-external-store@1.6.0", "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@13.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="], + + "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "https://registry.npmmirror.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], "vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "vaul": ["vaul@1.1.2", "https://registry.npmmirror.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "vfile": ["vfile@6.0.3", "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@37.3.6", "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "virtua": ["virtua@0.49.1", "", { "peerDependencies": { "react": ">=16.14.0", "react-dom": ">=16.14.0", "solid-js": ">=1.0", "svelte": ">=5.0", "vue": ">=3.2" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte", "vue"] }, "sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg=="], + "vite": ["vite@7.3.2", "https://registry.npmmirror.com/vite/-/vite-7.3.2.tgz", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], "void-elements": ["void-elements@3.1.0", "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1729,20 +2595,74 @@ "zustand": ["zustand@5.0.12", "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="], + "zwitch": ["zwitch@2.0.4", "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "@dotenvx/dotenvx/which": ["which@4.0.0", "https://registry.npmmirror.com/which/-/which-4.0.0.tgz", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/cache/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/serialize/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@gerrit0/mini-shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@gerrit0/mini-shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "https://registry.npmmirror.com/@shikijs/langs/-/langs-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@gerrit0/mini-shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "https://registry.npmmirror.com/@shikijs/themes/-/themes-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@gerrit0/mini-shiki/@shikijs/types": ["@shikijs/types@3.23.0", "https://registry.npmmirror.com/@shikijs/types/-/types-3.23.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + + "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], + + "@lobehub/ui/@base-ui/react": ["@base-ui/react@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui/utils": "0.2.3", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-4USBWz++DUSLTuIYpbYkSgy1F9ZmNG9S/lXvlUN6qMK0P0RlW+6eQmDUB4DgZ7HVvtXl4pvi4z5J2fv6Z3+9hg=="], + + "@lobehub/ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "https://registry.npmmirror.com/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/types": "3.23.0" } }, "sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ=="], + + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "@pierre/diffs/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "@rc-component/dialog/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + + "@rc-component/drawer/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + + "@rc-component/image/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + + "@rc-component/tour/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + + "@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="], + + "@rc-component/util/is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="], + + "@rc-component/util/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "@scalar/openapi-parser/@scalar/helpers": ["@scalar/helpers@0.5.2", "https://registry.npmmirror.com/@scalar/helpers/-/helpers-0.5.2.tgz", {}, "sha512-Pi1GAl8jO6ungmGj2sjDfCfqiBNrKW6HXDZmminV94ybGU/KtRLOqHwd0n9FIhY3j0RYGpGC0VCuniCICfQPHg=="], "@scalar/openapi-parser/@scalar/json-magic": ["@scalar/json-magic@0.12.8", "https://registry.npmmirror.com/@scalar/json-magic/-/json-magic-0.12.8.tgz", { "dependencies": { "@scalar/helpers": "0.5.2", "pathe": "^2.0.3", "yaml": "^2.8.0" } }, "sha512-a559iO8tmFeA90JJAAM3U5x1Asf3mr0Z8uDC1PmyLTDjdSOfajP7EY9VzNoXE2cM48ilf9qrjmkbw/d4VCFjQw=="], @@ -1783,18 +2703,34 @@ "@uppy/url/nanoid": ["nanoid@5.1.11", "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.11.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg=="], + "babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + "cliui/string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "engine.io/cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "eslint/ajv": ["ajv@6.15.0", "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "estree-util-to-js/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + "express/accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "express/cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "globby/ignore": ["ignore@7.0.5", "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], @@ -1803,10 +2739,22 @@ "headers-polyfill/set-cookie-parser": ["set-cookie-parser@3.1.0", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "leva/zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="], + "log-symbols/chalk": ["chalk@5.6.2", "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + + "mermaid/uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1817,12 +2765,24 @@ "orval/find-up": ["find-up@8.0.0", "https://registry.npmmirror.com/find-up/-/find-up-8.0.0.tgz", { "dependencies": { "locate-path": "^8.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww=="], + "parse-entities/@types/unist": ["@types/unist@2.0.11", "https://registry.npmmirror.com/@types/unist/-/unist-2.0.11.tgz", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "prompts/kleur": ["kleur@3.0.3", "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "proper-lockfile/retry": ["retry@0.12.0", "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="], + + "rc-util/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + "recharts/immer": ["immer@10.2.0", "https://registry.npmmirror.com/immer/-/immer-10.2.0.tgz", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], "restore-cursor/onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -1831,8 +2791,16 @@ "send/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "shadcn/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "shiki-stream/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + "string-width/strip-ansi": ["strip-ansi@7.2.0", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], "tus-js-client/is-stream": ["is-stream@2.0.1", "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -1861,6 +2829,24 @@ "@eslint/eslintrc/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + "@lobehub/ui/@base-ui/react/@base-ui/utils": ["@base-ui/utils@0.2.3", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-/CguQ2PDaOzeVOkllQR8nocJ0FFIDqsWIcURsVmm53QGo8NhFNpePjNlyPIB41luxfOqnG7PU0xicMEw3ls7XQ=="], + + "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "https://registry.npmmirror.com/@shikijs/types/-/types-3.23.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@pierre/diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.23.0", "https://registry.npmmirror.com/@shikijs/langs/-/langs-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "https://registry.npmmirror.com/@shikijs/themes/-/themes-3.23.0.tgz", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "https://registry.npmmirror.com/@shikijs/types/-/types-3.23.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "@tanstack/react-form/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "https://registry.npmmirror.com/@tanstack/store/-/store-0.9.3.tgz", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/react-router/@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.9.3", "https://registry.npmmirror.com/@tanstack/store/-/store-0.9.3.tgz", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], @@ -1869,8 +2855,16 @@ "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + "babel-plugin-macros/cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "express/accepts/negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -1883,6 +2877,8 @@ "send/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "shiki-stream/@shikijs/core/@shikijs/types": ["@shikijs/types@3.23.0", "https://registry.npmmirror.com/@shikijs/types/-/types-3.23.0.tgz", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "type-is/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], diff --git a/deploy/.helmignore b/deploy/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/deploy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/deploy/Chart.yaml b/deploy/Chart.yaml new file mode 100644 index 0000000..8a6482d --- /dev/null +++ b/deploy/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: deploy +description: Helm chart for the project backend services +type: application +version: 0.1.0 +appVersion: "0.2.9" \ No newline at end of file diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..13ac6d7 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,198 @@ +# Deploy Helm Chart + +Monolithic Helm chart for all backend services. + +## Services + +| Service | Port(s) | Replicas | HPA | Purpose | +|---|---|---|---|---| +| `app` | 3000 (HTTP) | 2 | 2–10 | Main API server | +| `gitserver` | 8021 (HTTP), 2222 (SSH) | 1 | 1–5 | Git HTTP + SSH server | +| `email_worker` | 8084 (HTTP) | 1 | disabled | Email queue consumer (single instance only) | +| `git_hook` | 8083 (HTTP) | 1 | 1–5 | Git hook worker pool | +| `metrics_aggregator` | 9090 (HTTP) | 1 | 1–5 | Prometheus scrape + Loki push | +| `static_server` | 8081 (HTTP) | 1 | 1–5 | Static file server (avatars, blobs, media) | + +## Prerequisites + +The following resources must exist in the cluster **before** installing the Helm chart. They are not managed by Helm — install, upgrade, and uninstall of the chart will not touch them. + +### 1. Namespace + +```bash +kubectl create namespace app +``` + +### 2. PVC (aliyun-nfs, 200Ti, ReadWriteMany) + +```bash +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: shared-data + namespace: app +spec: + accessModes: + - ReadWriteMany + resources: + requests: + storage: 200Ti + storageClassName: aliyun-nfs +EOF +``` + +> The chart references this PVC by name. If you use a different name, pass `--set pvcName=your-pvc-name` to Helm. + +### 3. ConfigMap + +```bash +kubectl apply -f - <<'EOF' +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-env + namespace: app +data: + APP_REPOS_ROOT: "/data/repos" + APP_AVATAR_PATH: "/data/avatars" + STORAGE_PATH: "/data/files" + STATIC_ROOT: "/data" + APP_LOG_LEVEL: "info" + APP_COOKIE_SECURE: "false" + APP_DOMAIN_URL: "https://your-domain.com" + APP_DATABASE_URL: "postgres://user:pass@postgres:5432/app" + APP_REDIS_URL: "redis://redis:6379" + APP_AI_BASIC_URL: "https://api.openai.com/v1" + APP_AI_API_KEY: "sk-..." + APP_SMTP_PASSWORD: "..." + APP_SESSION_SECRET: "min-32-byte-random-string..." + APP_SSH_SERVER_PRIVATE_KEY: "" +EOF +``` + +| Variable | Default / Example | Required | +|---|---|---| +| `APP_REPOS_ROOT` | `/data/repos` | Yes | +| `APP_AVATAR_PATH` | `/data/avatars` | Yes | +| `STORAGE_PATH` | `/data/files` | Yes | +| `STATIC_ROOT` | `/data` | Yes | +| `APP_LOG_LEVEL` | `info` | No | +| `APP_COOKIE_SECURE` | `false` | No | +| `APP_DOMAIN_URL` | `https://your-domain.com` | Yes | +| `APP_DATABASE_URL` | `postgres://...` | **Yes** | +| `APP_REDIS_URL` | `redis://...` | **Yes** | +| `APP_AI_BASIC_URL` | `https://api.openai.com/v1` | **Yes** | +| `APP_AI_API_KEY` | `sk-...` | **Yes** | +| `APP_SMTP_PASSWORD` | `...` | **Yes** | +| `APP_SESSION_SECRET` | min 32 bytes | **Yes** | +| `APP_SSH_SERVER_PRIVATE_KEY` | hex-encoded PEM | **Yes** | +| `APP_SSH_PORT` | `2222` | Yes (k8s) | + +> **SSH host key**: `APP_SSH_SERVER_PRIVATE_KEY` must be the hex-encoded Ed25519 private key PEM bytes. +> ```bash +> ssh-keygen -t ed25519 -f /tmp/ssh_host_key -N "" +> hexdump -v -e '/1 "%02x"' < /tmp/ssh_host_key +> ``` +> +> **Session secret**: generate 48 random bytes: +> ```bash +> openssl rand -base64 48 +> ``` +> +> Override the ConfigMap name with `--set configMapName=your-cm-name`. + +### 4. Verify prerequisites + +```bash +kubectl get namespace app +kubectl get pvc -n app shared-data +kubectl get configmap -n app app-env +``` + +## Quick Start + +```bash +helm template deploy ./deploy --namespace app --set imageRegistry=ghcr.io/your-org +helm lint ./deploy + +# Install +helm upgrade --install deploy ./deploy \ + --namespace app \ + --set imageRegistry=ghcr.io/your-org \ + --set imageTag=v0.2.9 +``` + +## Storage + +All services share a single PVC (`shared-data`) via `subPath` mounts: + +| SubPath | Mount | Used By | +|---|---|---| +| `repos` | `/data/repos` | app, gitserver, git-hook | +| `avatars` | `/data/avatars` | app | +| `files` | `/data/files` | app | +| `static` | `/data` | static-server | + +## Autoscaling + +All services except `email_worker` have HPA enabled by default. The email worker is fixed at 1 replica and must not be scaled. + +To adjust HPA bounds per service: + +```bash +--set services.app.autoscaling.maxReplicas=20 +--set services.app.autoscaling.targetCPUUtilization=70 +``` + +To disable HPA for a service: + +```bash +--set services.git_hook.autoscaling.enabled=false +``` + +## Ingress + +```bash +helm upgrade --install deploy ./deploy \ + --namespace app \ + --set ingress.enabled=true \ + --set ingress.className=nginx \ + --set ingress.hosts[0].host=your-domain.com +``` + +## Dependencies + +All services require these to be reachable from the cluster: + +- PostgreSQL (via `APP_DATABASE_URL`) +- Redis (via `APP_REDIS_URL`) +- Git binary (included in all Docker images) +- OpenAI-compatible API (via `APP_AI_BASIC_URL` + `APP_AI_API_KEY`) +- Qdrant vector DB (via `APP_QDRANT_URL`) +- SMTP server (via `APP_SMTP_*`) +- Embedding model (via `APP_EMBED_MODEL_*`) + +Optional dependencies with graceful degradation: + +| Dependency | Variable | Fallback | +|---|---|---| +| NATS JetStream | `NATS_URL` + `NATS_TOKEN` | Redis queue | +| Loki | `LOKI_URL` | Logs discarded | +| OTEL Collector | `OTEL_EXPORTER_OTLP_ENDPOINT` | Tracing disabled | + +## Production Example + +```bash +helm upgrade --install deploy ./deploy \ + --namespace app \ + --set imageRegistry=ghcr.io/your-org \ + --set imageTag=v0.2.9 \ + --set services.app.replicas=3 \ + --set services.app.autoscaling.maxReplicas=20 \ + --set ingress.enabled=true \ + --set ingress.className=nginx \ + --set ingress.hosts[0].host=your-domain.com \ + --set configMapName=app-env \ + --set pvcName=shared-data +``` diff --git a/deploy/gingress/deployment.yaml b/deploy/gingress/deployment.yaml new file mode 100644 index 0000000..58fd40c --- /dev/null +++ b/deploy/gingress/deployment.yaml @@ -0,0 +1,93 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gingress-controller + namespace: gingress-system + labels: + app: gingress +spec: + replicas: 2 + selector: + matchLabels: + app: gingress + template: + metadata: + labels: + app: gingress + spec: + serviceAccountName: gingress-controller + containers: + - name: gingress + image: gingress:latest + imagePullPolicy: IfNotPresent + args: + - "--ingress-class=gingress" + - "--bind-http=0.0.0.0:80" + - "--bind-https=0.0.0.0:443" + - "--metrics-bind=0.0.0.0:8080" + ports: + - name: http + containerPort: 80 + protocol: TCP + - name: https + containerPort: 443 + protocol: TCP + - name: metrics + containerPort: 8080 + protocol: TCP + env: + - name: RUST_LOG + value: "info" + - name: METRICS_PUSH_URL + value: "" # Optional: push to metrics aggregator + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /readyz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: gingress + topologyKey: kubernetes.io/hostname +--- +apiVersion: v1 +kind: Service +metadata: + name: gingress + namespace: gingress-system +spec: + type: LoadBalancer + selector: + app: gingress + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP + - name: https + port: 443 + targetPort: 443 + protocol: TCP + - name: metrics + port: 8080 + targetPort: 8080 + protocol: TCP diff --git a/deploy/gingress/rbac.yaml b/deploy/gingress/rbac.yaml new file mode 100644 index 0000000..885284e --- /dev/null +++ b/deploy/gingress/rbac.yaml @@ -0,0 +1,48 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gingress-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: gingress-controller + namespace: gingress-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: gingress-controller +rules: + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses", "ingressclasses"] + verbs: ["get", "list", "watch"] + - apiGroups: ["networking.k8s.io"] + resources: ["ingresses/status"] + verbs: ["update", "patch"] + - apiGroups: [""] + resources: ["services", "endpoints", "endpointslices", "secrets", "nodes"] + verbs: ["get", "list", "watch"] + - apiGroups: ["discovery.k8s.io"] + resources: ["endpointslices"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: gingress-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: gingress-controller +subjects: + - kind: ServiceAccount + name: gingress-controller + namespace: gingress-system +--- +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + name: gingress +spec: + controller: gingress.io/gingress-controller diff --git a/deploy/templates/NOTES.txt b/deploy/templates/NOTES.txt new file mode 100644 index 0000000..e80c939 --- /dev/null +++ b/deploy/templates/NOTES.txt @@ -0,0 +1,19 @@ +Project backend services deployed to namespace: {{ .Release.Namespace }} + +Services: +{{- range $svcKey, $svcVal := .Values.services }} + {{ $svcKey | replace "_" "-" }}: {{ if $svcVal.ports }}{{ range $portName, $portNum := $svcVal.ports }}{{ $portName }}={{ $portNum }} {{ end }}{{ else }}port={{ $svcVal.port }}{{ end }} {{ if $svcVal.autoscaling.enabled }}(HPA: {{ $svcVal.autoscaling.minReplicas }}-{{ $svcVal.autoscaling.maxReplicas }}){{ else }}(static: {{ $svcVal.replicaCount }}){{ end }} +{{- end }} + +To access the app locally: + kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "deploy.serviceFullname" (dict "root" . "svcKey" "app") }} 3000:3000 + +To check HPA status: +{{- range $svcKey, $svcVal := .Values.services }} +{{- if $svcVal.autoscaling.enabled }} + kubectl get hpa -n {{ $.Release.Namespace }} {{ include "deploy.serviceFullname" (dict "root" $ "svcKey" $svcKey) }} +{{- end }} +{{- end }} + +To check all pods: + kubectl get pods -n {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "deploy.name" . }}" diff --git a/deploy/templates/_helpers.tpl b/deploy/templates/_helpers.tpl new file mode 100644 index 0000000..cd3f7b7 --- /dev/null +++ b/deploy/templates/_helpers.tpl @@ -0,0 +1,78 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "deploy.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "deploy.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Service fullname — includes service key for per-service resources. +Underscores in svcKey are replaced with hyphens for valid Kubernetes names. +*/}} +{{- define "deploy.serviceFullname" -}} +{{- printf "%s-%s" (include "deploy.fullname" .root) (.svcKey | replace "_" "-") | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Chart name and version as used by the chart label. +*/}} +{{- define "deploy.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "deploy.labels" -}} +helm.sh/chart: {{ include "deploy.chart" . }} +{{ include "deploy.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "deploy.selectorLabels" -}} +app.kubernetes.io/name: {{ include "deploy.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Per-service selector labels — used by Service to target the right Deployment. +Underscores in svcKey are replaced with hyphens for valid Kubernetes label values. +*/}} +{{- define "deploy.serviceSelectorLabels" -}} +app.kubernetes.io/name: {{ include "deploy.name" .root }} +app.kubernetes.io/instance: {{ .root.Release.Name }} +app.kubernetes.io/component: {{ .svcKey | replace "_" "-" }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "deploy.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "deploy.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/templates/app/deployment.yaml b/deploy/templates/app/deployment.yaml new file mode 100644 index 0000000..cfebdae --- /dev/null +++ b/deploy/templates/app/deployment.yaml @@ -0,0 +1,89 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "app") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + replicas: {{ .Values.services.app.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "app") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: app + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: app + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.app.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + {{- with .Values.services.app.command }} + command: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.services.app.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + {{- with .Values.services.app.extraEnv }} + env: + {{- range $key, $val := . }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + {{- end }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.app.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.services.app.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ .Values.pvcName }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/app/service.yaml b/deploy/templates/app/service.yaml new file mode 100644 index 0000000..63900d6 --- /dev/null +++ b/deploy/templates/app/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "app") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: app +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.app.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "app") | nindent 4 }} \ No newline at end of file diff --git a/deploy/templates/email_worker/deployment.yaml b/deploy/templates/email_worker/deployment.yaml new file mode 100644 index 0000000..d52dc25 --- /dev/null +++ b/deploy/templates/email_worker/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "email_worker") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: email-worker +spec: + replicas: {{ .Values.services.email_worker.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "email_worker") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: email-worker + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: email_worker + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.email_worker.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ .Values.services.email_worker.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.email_worker.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/email_worker/service.yaml b/deploy/templates/email_worker/service.yaml new file mode 100644 index 0000000..0aa751e --- /dev/null +++ b/deploy/templates/email_worker/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "email_worker") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: email-worker +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.email_worker.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "email_worker") | nindent 4 }} \ No newline at end of file diff --git a/deploy/templates/git_hook/deployment.yaml b/deploy/templates/git_hook/deployment.yaml new file mode 100644 index 0000000..51ff724 --- /dev/null +++ b/deploy/templates/git_hook/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "git_hook") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: git-hook +spec: + replicas: {{ .Values.services.git_hook.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "git_hook") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: git-hook + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: git-hook + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.git_hook.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ .Values.services.git_hook.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.git_hook.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.services.git_hook.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ .Values.pvcName }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/git_hook/service.yaml b/deploy/templates/git_hook/service.yaml new file mode 100644 index 0000000..a8e5cda --- /dev/null +++ b/deploy/templates/git_hook/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "git_hook") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: git-hook +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.git_hook.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "git_hook") | nindent 4 }} \ No newline at end of file diff --git a/deploy/templates/gitserver/deployment.yaml b/deploy/templates/gitserver/deployment.yaml new file mode 100644 index 0000000..0cd6dde --- /dev/null +++ b/deploy/templates/gitserver/deployment.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "gitserver") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: gitserver +spec: + replicas: {{ .Values.services.gitserver.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "gitserver") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: gitserver + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: gitserver + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.gitserver.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ .Values.services.gitserver.ports.http }} + protocol: TCP + - name: ssh + containerPort: {{ .Values.services.gitserver.ports.ssh }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + {{- with .Values.services.gitserver.extraEnv }} + env: + {{- range $key, $val := . }} + - name: {{ $key }} + value: {{ $val | quote }} + {{- end }} + {{- end }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.gitserver.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.services.gitserver.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ .Values.pvcName }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/gitserver/service.yaml b/deploy/templates/gitserver/service.yaml new file mode 100644 index 0000000..c6eaadc --- /dev/null +++ b/deploy/templates/gitserver/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "gitserver") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: gitserver +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.gitserver.ports.http }} + targetPort: http + protocol: TCP + name: http + - port: {{ .Values.services.gitserver.ports.ssh }} + targetPort: ssh + protocol: TCP + name: ssh + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "gitserver") | nindent 4 }} \ No newline at end of file diff --git a/deploy/templates/hpa.yaml b/deploy/templates/hpa.yaml new file mode 100644 index 0000000..41ed0a0 --- /dev/null +++ b/deploy/templates/hpa.yaml @@ -0,0 +1,26 @@ +{{- range $svcKey, $svcVal := .Values.services }} +{{- if $svcVal.autoscaling.enabled }} +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" $ "svcKey" $svcKey) }} + labels: + {{- include "deploy.labels" $ | nindent 4 }} + app.kubernetes.io/component: {{ $svcKey | replace "_" "-" }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "deploy.serviceFullname" (dict "root" $ "svcKey" $svcKey) }} + minReplicas: {{ $svcVal.autoscaling.minReplicas }} + maxReplicas: {{ $svcVal.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svcVal.autoscaling.targetCPUUtilization }} +{{- end }} +{{- end }} diff --git a/deploy/templates/ingress.yaml b/deploy/templates/ingress.yaml new file mode 100644 index 0000000..0283ddd --- /dev/null +++ b/deploy/templates/ingress.yaml @@ -0,0 +1,41 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "deploy.fullname" . }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "deploy.serviceFullname" (dict "root" $ "svcKey" .serviceName) }} + port: + number: {{ .servicePort }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/deploy/templates/metrics_aggregator/deployment.yaml b/deploy/templates/metrics_aggregator/deployment.yaml new file mode 100644 index 0000000..980f3fd --- /dev/null +++ b/deploy/templates/metrics_aggregator/deployment.yaml @@ -0,0 +1,70 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "metrics_aggregator") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: metrics-aggregator +spec: + replicas: {{ .Values.services.metrics_aggregator.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "metrics_aggregator") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: metrics-aggregator + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: metrics_aggregator + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.metrics_aggregator.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ .Values.services.metrics_aggregator.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.metrics_aggregator.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/metrics_aggregator/service.yaml b/deploy/templates/metrics_aggregator/service.yaml new file mode 100644 index 0000000..30945ae --- /dev/null +++ b/deploy/templates/metrics_aggregator/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "metrics_aggregator") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: metrics-aggregator +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.metrics_aggregator.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "metrics_aggregator") | nindent 4 }} \ No newline at end of file diff --git a/deploy/templates/secret.yaml b/deploy/templates/secret.yaml new file mode 100644 index 0000000..1adda38 --- /dev/null +++ b/deploy/templates/secret.yaml @@ -0,0 +1 @@ +{{/* Secret disabled — all config via ConfigMap */}} diff --git a/deploy/templates/serviceaccount.yaml b/deploy/templates/serviceaccount.yaml new file mode 100644 index 0000000..8167738 --- /dev/null +++ b/deploy/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "deploy.serviceAccountName" . }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/templates/static_server/deployment.yaml b/deploy/templates/static_server/deployment.yaml new file mode 100644 index 0000000..30740f5 --- /dev/null +++ b/deploy/templates/static_server/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "static_server") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: static-server +spec: + replicas: {{ .Values.services.static_server.replicaCount | default 1 }} + selector: + matchLabels: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "static_server") | nindent 6 }} + template: + metadata: + labels: + {{- include "deploy.labels" . | nindent 8 }} + app.kubernetes.io/component: static-server + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "deploy.serviceAccountName" . }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: static_server + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + image: "{{ .Values.imageRegistry }}/{{ .Values.services.static_server.repository }}:{{ .Values.imageTag | default .Chart.AppVersion }}" + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: {{ .Values.services.static_server.port }} + protocol: TCP + envFrom: + - configMapRef: + name: {{ .Values.configMapName }} + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 15 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + {{- with .Values.services.static_server.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.services.static_server.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: shared-data + persistentVolumeClaim: + claimName: {{ .Values.pvcName }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/deploy/templates/static_server/service.yaml b/deploy/templates/static_server/service.yaml new file mode 100644 index 0000000..fa555fc --- /dev/null +++ b/deploy/templates/static_server/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "deploy.serviceFullname" (dict "root" . "svcKey" "static_server") }} + labels: + {{- include "deploy.labels" . | nindent 4 }} + app.kubernetes.io/component: static-server +spec: + type: ClusterIP + ports: + - port: {{ .Values.services.static_server.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "deploy.serviceSelectorLabels" (dict "root" . "svcKey" "static_server") | nindent 4 }} \ No newline at end of file diff --git a/deploy/values.yaml b/deploy/values.yaml new file mode 100644 index 0000000..289f9e2 --- /dev/null +++ b/deploy/values.yaml @@ -0,0 +1,182 @@ +# Global image registry and tag +imageRegistry: "" +imageTag: "" + +# External ConfigMap (managed outside Helm) +configMapName: "app-env" + +# Service definitions +services: + app: + repository: app + port: 3000 + replicaCount: 2 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilization: 80 + command: + - "app" + - "--bind" + - "0.0.0.0:3000" + resources: + requests: + cpu: 200m + memory: 256Mi + limits: + cpu: "1" + memory: 512Mi + volumeMounts: + - name: shared-data + mountPath: /data/repos + subPath: repos + - name: shared-data + mountPath: /data/avatars + subPath: avatars + - name: shared-data + mountPath: /data/files + subPath: files + + email_worker: + repository: email-worker + port: 8084 + replicaCount: 1 + autoscaling: + enabled: false # email must stay at 1 replica + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + + git_hook: + repository: git-hook + port: 8083 + replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilization: 80 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - name: shared-data + mountPath: /data/repos + subPath: repos + + gitserver: + repository: gitserver + ports: + http: 8021 + ssh: 2222 + replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilization: 80 + # SSH port must match the containerPort + extraEnv: + APP_SSH_PORT: "2222" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + volumeMounts: + - name: shared-data + mountPath: /data/repos + subPath: repos + + metrics_aggregator: + repository: metrics-aggregator + port: 9090 + replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilization: 80 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + + static_server: + repository: static-server + port: 8081 + replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilization: 80 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + volumeMounts: + - name: shared-data + mountPath: /data + subPath: static + +# External PVC (managed outside Helm — not deleted on uninstall) +pvcName: "shared-data" + +# Ingress — only for the main app service +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: chart-example.local + paths: + - path: / + pathType: Prefix + serviceName: app + servicePort: 3000 + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + +securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + +nodeSelector: {} +tolerations: [] +affinity: {} \ No newline at end of file diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile new file mode 100644 index 0000000..addf1e3 --- /dev/null +++ b/docker/app.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 openssh-client procps git \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/app /bin +USER appuser +EXPOSE 3000 +CMD ["app"] \ No newline at end of file diff --git a/docker/email.Dockerfile b/docker/email.Dockerfile new file mode 100644 index 0000000..4c94739 --- /dev/null +++ b/docker/email.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/email-worker /bin +USER appuser +EXPOSE 8084 +CMD ["email-worker"] \ No newline at end of file diff --git a/docker/gingress.Dockerfile b/docker/gingress.Dockerfile new file mode 100644 index 0000000..e2a5cb1 --- /dev/null +++ b/docker/gingress.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/gingress /bin +USER appuser +EXPOSE 80 443 8080 +CMD ["gingress"] diff --git a/docker/githook.Dockerfile b/docker/githook.Dockerfile new file mode 100644 index 0000000..d51a21e --- /dev/null +++ b/docker/githook.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 git \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/git-hook /bin +USER appuser +EXPOSE 8083 +CMD ["git-hook"] \ No newline at end of file diff --git a/docker/gitserver.Dockerfile b/docker/gitserver.Dockerfile new file mode 100644 index 0000000..b067cf5 --- /dev/null +++ b/docker/gitserver.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 git openssh-client \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/gitserver /bin +USER appuser +EXPOSE 8021 2222 +CMD ["gitserver"] \ No newline at end of file diff --git a/docker/metrics.Dockerfile b/docker/metrics.Dockerfile new file mode 100644 index 0000000..0d9dc0b --- /dev/null +++ b/docker/metrics.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/metrics-aggregator /bin +USER appuser +EXPOSE 9090 +CMD ["metrics-aggregator"] \ No newline at end of file diff --git a/docker/static.Dockerfile b/docker/static.Dockerfile new file mode 100644 index 0000000..49766cc --- /dev/null +++ b/docker/static.Dockerfile @@ -0,0 +1,10 @@ +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libssl3 \ + && rm -rf /var/lib/apt/lists/* +RUN useradd --system --create-home appuser +WORKDIR /home/appuser +COPY target/release/static-server /bin +USER appuser +EXPOSE 8081 +CMD ["static-server"] \ No newline at end of file diff --git a/libs/agent/Cargo.toml b/libs/agent/Cargo.toml index 366da2a..5541839 100644 --- a/libs/agent/Cargo.toml +++ b/libs/agent/Cargo.toml @@ -42,5 +42,6 @@ reqwest = { workspace = true, features = ["json"] } utoipa = { workspace = true } tokio-stream = { workspace = true } redis = { workspace = true, features = ["tokio-comp"] } +queue = { workspace = true } [lints] workspace = true diff --git a/libs/agent/agent/service.rs b/libs/agent/agent/service.rs index 92a3fc9..3ac8fe1 100644 --- a/libs/agent/agent/service.rs +++ b/libs/agent/agent/service.rs @@ -152,7 +152,8 @@ impl RigAgentService { Ok(MultiTurnStreamItem::StreamAssistantItem( StreamedAssistantContent::Text(text), )) => { - let _ = tx.send(Ok(StreamChunk::Text(text.text.clone()))).await; + let cleaned = text.text.replace('\n', ""); + let _ = tx.send(Ok(StreamChunk::Text(cleaned))).await; final_content.push_str(&text.text); } Ok(MultiTurnStreamItem::StreamAssistantItem( @@ -237,7 +238,8 @@ impl RigAgentService { Ok(MultiTurnStreamItem::StreamAssistantItem( StreamedAssistantContent::Text(text), )) => { - let _ = tx.send(Ok(StreamChunk::Text(text.text.clone()))).await; + let cleaned = text.text.replace('\n', ""); + let _ = tx.send(Ok(StreamChunk::Text(cleaned))).await; final_content.push_str(&text.text); } Ok(MultiTurnStreamItem::FinalResponse(resp)) => { diff --git a/libs/agent/billing.rs b/libs/agent/billing.rs index 3d7dcd3..304562b 100644 --- a/libs/agent/billing.rs +++ b/libs/agent/billing.rs @@ -1,226 +1,455 @@ -//! AI usage billing — records token costs against a project or workspace balance. +//! Billing service — handles user-level and project-level billing, deduction, +//! credit initialization, and error persistence. //! -//! All functions take `&DatabaseConnection` instead of `&AppService`. +//! Architecture: +//! - Each user gets $10 personal balance on signup. +//! - Each project gets $20 balance only if it's the creator's first project, +//! $0 otherwise. +//! - AI usage is deducted from the project balance first; if insufficient, +//! falls through to the user's personal balance. +//! - Monthly quota only applies to pro users (is_pro = true). +//! - If both project and user balance are insufficient, a billing_error +//! record is persisted and an error is returned to the caller. use db::database::AppDatabase; use models::agents::model_pricing; -use models::projects::project; -use models::projects::project_billing; -use models::projects::project_billing_history; -use models::workspaces::workspace_billing; -use models::workspaces::workspace_billing_history; +use models::ai::billing_error; +use models::projects::{project, project_billing, project_billing_history}; +use models::users::user_billing; use rust_decimal::Decimal; use sea_orm::*; use uuid::Uuid; use crate::error::AgentError; +// ── Constants ── + +fn default_user_balance() -> Decimal { Decimal::new(100_000, 4) } // $10.0000 +fn first_project_credit() -> Decimal { Decimal::new(200_000, 4) } // $20.0000 +const SUBSEQUENT_PROJECT_BALANCE: Decimal = Decimal::ZERO; + +// ── Types ── + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] pub struct BillingRecord { pub cost: f64, pub currency: String, pub input_tokens: i64, pub output_tokens: i64, + pub deducted_from: String, // "project" or "user" } -/// Extended result that includes insufficient balance flag for system message creation. #[derive(Debug)] pub enum BillingResult { Success(BillingRecord), InsufficientBalance { message: String }, } -/// Record AI usage for a project with cascading billing. +// ── Core deduction: AI usage ── + +/// Record AI usage: deduct from project balance first, fall through to user balance. /// -/// Billing strategy: -/// 1. Try to deduct from project balance first -/// 2. If insufficient, fallback to workspace balance (if project belongs to workspace) -/// 3. If both insufficient or no workspace, return InsufficientBalance error with room_id -/// -/// Returns BillingError::InsufficientBalance with room_id for system message creation. +/// Returns `InsufficientBalance` if neither account can cover the cost. +/// On insufficient balance, a `billing_error` record is persisted for frontend display. pub async fn record_ai_usage( db: &AppDatabase, project_uid: Uuid, + user_uid: Uuid, model_id: Uuid, input_tokens: i64, output_tokens: i64, ) -> Result { - // 1. Look up the active price for this model. + let total_cost = compute_cost(db, model_id, input_tokens, output_tokens).await?; + let currency = get_currency(db, model_id).await?; + + // Verify project exists + let _ = project::Entity::find_by_id(project_uid) + .one(db) + .await? + .ok_or_else(|| AgentError::Internal("Project not found".into()))?; + + // Attempt project-level deduction first + let project_result = deduct_from_project(db, project_uid, total_cost, ¤cy, model_id, input_tokens, output_tokens).await; + + match project_result { + Ok(()) => { + let cost_f64 = decimal_to_f64(total_cost); + tracing::info!( + project_id = %project_uid, + model_id = %model_id, + input_tokens, output_tokens, + cost = %cost_f64, + currency = %currency, + deducted_from = "project", + "ai_usage_recorded" + ); + Ok(BillingResult::Success(BillingRecord { + cost: cost_f64, + currency, + input_tokens, + output_tokens, + deducted_from: "project".to_string(), + })) + } + Err(_) => { + // Project balance insufficient — try user personal balance + let user_result = deduct_from_user(db, user_uid, total_cost, ¤cy, project_uid, model_id, input_tokens, output_tokens).await; + + match user_result { + Ok(()) => { + let cost_f64 = decimal_to_f64(total_cost); + tracing::info!( + user_id = %user_uid, + project_id = %project_uid, + model_id = %model_id, + input_tokens, output_tokens, + cost = %cost_f64, + currency = %currency, + deducted_from = "user", + "ai_usage_recorded" + ); + Ok(BillingResult::Success(BillingRecord { + cost: cost_f64, + currency, + input_tokens, + output_tokens, + deducted_from: "user".to_string(), + })) + } + Err(insufficient_msg) => { + // Both project and user balance insufficient — persist error + persist_billing_error( + db, + "project", + project_uid, + "insufficient_balance", + &insufficient_msg, + Some(serde_json::json!({ + "user_id": user_uid.to_string(), + "model_id": model_id.to_string(), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "cost": decimal_to_f64(total_cost), + "currency": currency, + })), + ).await?; + + Ok(BillingResult::InsufficientBalance { + message: insufficient_msg, + }) + } + } + } + } +} + +/// Check whether a project + user has sufficient combined balance for a potential AI call. +/// Called before starting AI processing to avoid wasted compute. +pub async fn check_balance( + db: &AppDatabase, + project_uid: Uuid, + user_uid: Uuid, + model_id: Uuid, + estimated_input_tokens: i64, + estimated_output_tokens: i64, +) -> Result { + let estimated_cost = compute_cost(db, model_id, estimated_input_tokens, estimated_output_tokens).await?; + let project_balance = get_project_balance(db, project_uid).await; + let user_balance = get_user_balance(db, user_uid).await; + + Ok(project_balance + user_balance >= estimated_cost) +} + +// ── Initialization ── + +/// Initialize a user billing account with the default $10 balance. +/// Called on user signup / first login. +pub async fn initialize_user_billing(db: &AppDatabase, user_uid: Uuid) -> Result<(), AgentError> { + let now = chrono::Utc::now(); + user_billing::ActiveModel { + user: Set(user_uid), + balance: Set(default_user_balance()), + currency: Set("USD".to_string()), + is_pro: Set(false), + monthly_quota: Set(Decimal::ZERO), + month_used: Set(Decimal::ZERO), + cycle_start: Set(None), + cycle_end: Set(None), + updated_at: Set(now), + created_at: Set(now), + } + .insert(db) + .await + .map_err(|e| AgentError::Internal(format!("failed to create user billing: {}", e)))?; + + tracing::info!(user_id = %user_uid, balance = "$10", "user_billing_initialized"); + Ok(()) +} + +/// Initialize a project billing account. +/// Grants $20 only if this is the creator's first project; $0 otherwise. +pub async fn initialize_project_billing( + db: &AppDatabase, + project_uid: Uuid, + creator_uid: Uuid, +) -> Result<(), AgentError> { + // Check how many projects this user has already created + let existing_count = project::Entity::find() + .filter(project::Column::CreatedBy.eq(creator_uid)) + .count(db) + .await + .map_err(|e| AgentError::Internal(format!("failed to count user projects: {}", e)))?; + + let is_first = existing_count == 0; + let initial_balance = if is_first { first_project_credit() } else { SUBSEQUENT_PROJECT_BALANCE }; + let now = chrono::Utc::now(); + + project_billing::ActiveModel { + project: Set(project_uid), + balance: Set(initial_balance), + currency: Set("USD".to_string()), + user: Set(Some(creator_uid)), + initial_credit_granted: Set(is_first), + is_pro: Set(false), + monthly_quota: Set(Decimal::ZERO), + month_used: Set(Decimal::ZERO), + cycle_start: Set(None), + cycle_end: Set(None), + updated_at: Set(now), + created_at: Set(now), + } + .insert(db) + .await + .map_err(|e| AgentError::Internal(format!("failed to create project billing: {}", e)))?; + + if is_first { + // Record the credit in billing history + project_billing_history::ActiveModel { + uid: Set(Uuid::new_v4()), + project: Set(project_uid), + user: Set(Some(creator_uid)), + amount: Set(first_project_credit()), + currency: Set("USD".to_string()), + reason: Set("first_project_credit".to_string()), + extra: Set(Some(serde_json::json!({ + "is_first_project": true, + }))), + created_at: Set(now), + ..Default::default() + } + .insert(db) + .await + .map_err(|e| AgentError::Internal(format!("failed to record credit history: {}", e)))?; + } + + tracing::info!( + project_id = %project_uid, + creator_id = %creator_uid, + is_first_project = is_first, + balance = if is_first { "$20" } else { "$0" }, + "project_billing_initialized" + ); + Ok(()) +} + +// ── Internal helpers ── + +async fn compute_cost( + db: &AppDatabase, + model_id: Uuid, + input_tokens: i64, + output_tokens: i64, +) -> Result { let pricing = model_pricing::Entity::find() .filter(model_pricing::Column::ModelVersionId.eq(model_id)) .order_by_desc(model_pricing::Column::EffectiveFrom) .one(db) .await? - .ok_or_else(|| { - AgentError::Internal( - "No pricing record found for this model. Please configure AI model pricing first." - .into(), - ) - })?; + .ok_or_else(|| AgentError::Internal( + "No pricing record found for this model. Please configure AI model pricing first.".into(), + ))?; + + let input_price: Decimal = pricing.input_price_per_1k_tokens.parse() + .map_err(|e| AgentError::Internal(format!("Invalid input price: {}", e)))?; + let output_price: Decimal = pricing.output_price_per_1k_tokens.parse() + .map_err(|e| AgentError::Internal(format!("Invalid output price: {}", e)))?; - // 2. Compute cost using Decimal arithmetic. - let input_price: Decimal = pricing - .input_price_per_1k_tokens - .parse() - .map_err(|e| AgentError::Internal(format!("Invalid input price format: {}", e)))?; - let output_price: Decimal = pricing - .output_price_per_1k_tokens - .parse() - .map_err(|e| AgentError::Internal(format!("Invalid output price format: {}", e)))?; - let tokens_i = Decimal::from(input_tokens); - let tokens_o = Decimal::from(output_tokens); let thousand = Decimal::from(1000); + Ok((Decimal::from(input_tokens) / thousand) * input_price + + (Decimal::from(output_tokens) / thousand) * output_price) +} - let total_cost = (tokens_i / thousand) * input_price - + (tokens_o / thousand) * output_price; - - let currency = pricing.currency.clone(); - - // 3. Cascading billing: project balance first, then workspace if insufficient. - let proj = project::Entity::find_by_id(project_uid) +async fn get_currency(db: &AppDatabase, model_id: Uuid) -> Result { + let pricing = model_pricing::Entity::find() + .filter(model_pricing::Column::ModelVersionId.eq(model_id)) .one(db) .await? - .ok_or_else(|| AgentError::Internal("Project not found".into()))?; + .ok_or_else(|| AgentError::Internal("No pricing found".into()))?; + Ok(pricing.currency.clone()) +} - let txn = db.begin().await?; +async fn get_project_balance(db: &AppDatabase, project_uid: Uuid) -> Decimal { + project_billing::Entity::find_by_id(project_uid) + .one(db) + .await + .ok() + .flatten() + .map(|b| b.balance) + .unwrap_or(Decimal::ZERO) +} - // Always check project balance first - let project_billing = project_billing::Entity::find_by_id(project_uid) +async fn get_user_balance(db: &AppDatabase, user_uid: Uuid) -> Decimal { + user_billing::Entity::find_by_id(user_uid) + .one(db) + .await + .ok() + .flatten() + .map(|b| b.balance) + .unwrap_or(Decimal::ZERO) +} + +async fn deduct_from_project( + db: &AppDatabase, + project_uid: Uuid, + cost: Decimal, + currency: &str, + model_id: Uuid, + input_tokens: i64, + output_tokens: i64, +) -> Result<(), String> { + let txn = db.begin().await.map_err(|e| format!("db txn error: {}", e))?; + + let billing = project_billing::Entity::find_by_id(project_uid) .lock_exclusive() .one(&txn) - .await? - .ok_or_else(|| AgentError::Internal("Project billing account not found".into()))?; + .await + .map_err(|e| format!("db error: {}", e))? + .ok_or_else(|| "Project billing account not found".to_string())?; + + if billing.balance < cost { + txn.rollback().await.ok(); + return Err(format!( + "Project balance insufficient. Required: {:.4} {}, Available: {:.4} {}", + cost, currency, billing.balance, currency + )); + } let now = chrono::Utc::now(); - if project_billing.balance >= total_cost { - // ── Project has sufficient balance ────────────────────────── - let amount_dec = -total_cost; - - project_billing_history::ActiveModel { - uid: Set(Uuid::new_v4()), - project: Set(project_uid), - user: Set(None), - amount: Set(amount_dec), - currency: Set(currency.clone()), - reason: Set("ai_usage".to_string()), - extra: Set(Some(serde_json::json!({ - "model_id": model_id.to_string(), - "input_tokens": input_tokens, - "output_tokens": output_tokens, - }))), - created_at: Set(now), - ..Default::default() - } - .insert(&txn) - .await?; - - let new_balance = project_billing.balance - total_cost; - let mut updated: project_billing::ActiveModel = project_billing.into(); - updated.balance = Set(new_balance); - updated.updated_at = Set(now); - updated.update(&txn).await?; - - txn.commit().await?; - - let cost_f64 = total_cost.to_string().parse().unwrap_or(0.0); - - tracing::info!( - project_id = %project_uid, - model_id = %model_id, - input_tokens = input_tokens, - output_tokens = output_tokens, - cost = %cost_f64, - currency = %currency, - source = "project", - "ai_usage_recorded" - ); - - Ok(BillingResult::Success(BillingRecord { - cost: cost_f64, - currency, - input_tokens, - output_tokens, - })) - } else if let Some(workspace_id) = proj.workspace_id { - // ── Project insufficient, fallback to workspace ───────────── - let workspace_billing = workspace_billing::Entity::find_by_id(workspace_id) - .lock_exclusive() - .one(&txn) - .await? - .ok_or_else(|| AgentError::Internal("Workspace billing account not found".into()))?; - - if workspace_billing.balance < total_cost { - txn.rollback().await?; - return Ok(BillingResult::InsufficientBalance { - message: format!( - "Insufficient balance. Project: {:.4} {}, Workspace: {:.4} {}, Required: {:.4} {}", - project_billing.balance, currency, - workspace_billing.balance, currency, - total_cost, currency - ), - }); - } - - let amount_dec = -total_cost; - - workspace_billing_history::ActiveModel { - uid: Set(Uuid::new_v4()), - workspace_id: Set(workspace_id), - user_id: Set(Some(proj.created_by)), - amount: Set(amount_dec), - currency: Set(currency.clone()), - reason: Set(format!("ai_usage:{}", project_uid)), - extra: Set(Some(serde_json::json!({ - "project_id": project_uid.to_string(), - "model_id": model_id.to_string(), - "input_tokens": input_tokens, - "output_tokens": output_tokens, - "fallback_reason": "project_balance_insufficient" - }))), - created_at: Set(now), - } - .insert(&txn) - .await?; - - let new_balance = workspace_billing.balance - total_cost; - let new_total_spent = workspace_billing.total_spent + total_cost; - let mut updated: workspace_billing::ActiveModel = workspace_billing.into(); - updated.balance = Set(new_balance); - updated.total_spent = Set(new_total_spent); - updated.updated_at = Set(now); - updated.update(&txn).await?; - - txn.commit().await?; - - let cost_f64 = total_cost.to_string().parse().unwrap_or(0.0); - - tracing::info!( - project_id = %project_uid, - model_id = %model_id, - input_tokens = input_tokens, - output_tokens = output_tokens, - cost = %cost_f64, - currency = %currency, - workspace_id = %workspace_id.to_string(), - source = "workspace_fallback", - "ai_usage_recorded" - ); - - Ok(BillingResult::Success(BillingRecord { - cost: cost_f64, - currency, - input_tokens, - output_tokens, - })) - } else { - // ── Project insufficient and no workspace ─────────────────── - txn.rollback().await?; - Ok(BillingResult::InsufficientBalance { - message: format!( - "Insufficient balance. Required: {:.4} {}, Available: {:.4} {}", - total_cost, currency, project_billing.balance, currency - ), - }) + project_billing_history::ActiveModel { + uid: Set(Uuid::new_v4()), + project: Set(project_uid), + user: Set(None), + amount: Set(-cost), + currency: Set(currency.to_string()), + reason: Set("ai_usage".to_string()), + extra: Set(Some(serde_json::json!({ + "model_id": model_id.to_string(), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "deducted_from": "project", + }))), + created_at: Set(now), + ..Default::default() } + .insert(&txn) + .await + .map_err(|e| format!("failed to insert history: {}", e))?; + + let mut updated: project_billing::ActiveModel = billing.into(); + updated.balance = Set(updated.balance.unwrap() - cost); + updated.updated_at = Set(now); + updated.update(&txn).await.map_err(|e| format!("failed to update balance: {}", e))?; + + txn.commit().await.map_err(|e| format!("commit error: {}", e))?; + Ok(()) } + +async fn deduct_from_user( + db: &AppDatabase, + user_uid: Uuid, + cost: Decimal, + currency: &str, + project_uid: Uuid, + model_id: Uuid, + input_tokens: i64, + output_tokens: i64, +) -> Result<(), String> { + let txn = db.begin().await.map_err(|e| format!("db txn error: {}", e))?; + + let billing = user_billing::Entity::find_by_id(user_uid) + .lock_exclusive() + .one(&txn) + .await + .map_err(|e| format!("db error: {}", e))? + .ok_or_else(|| "User billing account not found".to_string())?; + + if billing.balance < cost { + txn.rollback().await.ok(); + return Err(format!( + "Insufficient balance (project + user). Project: unavailable, User: {:.4} {}. Required: {:.4} {}", + billing.balance, currency, cost, currency + )); + } + + let now = chrono::Utc::now(); + + // Record in project billing history (but deducted from user) + project_billing_history::ActiveModel { + uid: Set(Uuid::new_v4()), + project: Set(project_uid), + user: Set(Some(user_uid)), + amount: Set(-cost), + currency: Set(currency.to_string()), + reason: Set("ai_usage_user_fallback".to_string()), + extra: Set(Some(serde_json::json!({ + "model_id": model_id.to_string(), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "deducted_from": "user", + }))), + created_at: Set(now), + ..Default::default() + } + .insert(&txn) + .await + .map_err(|e| format!("failed to insert history: {}", e))?; + + let mut updated: user_billing::ActiveModel = billing.into(); + updated.balance = Set(updated.balance.unwrap() - cost); + updated.updated_at = Set(now); + updated.update(&txn).await.map_err(|e| format!("failed to update user balance: {}", e))?; + + txn.commit().await.map_err(|e| format!("commit error: {}", e))?; + Ok(()) +} + +pub async fn persist_billing_error( + db: &AppDatabase, + scope: &str, + scope_id: Uuid, + error_type: &str, + message: &str, + details: Option, +) -> Result<(), AgentError> { + billing_error::ActiveModel { + id: Set(Uuid::new_v4()), + scope: Set(scope.to_string()), + scope_id: Set(scope_id), + error_type: Set(error_type.to_string()), + message: Set(message.to_string()), + details: Set(details), + resolved: Set(false), + created_at: Set(chrono::Utc::now()), + } + .insert(db) + .await + .map_err(|e| AgentError::Internal(format!("failed to persist billing error: {}", e)))?; + + tracing::warn!(scope, %scope_id, error_type, "billing_error_persisted"); + Ok(()) +} + +fn decimal_to_f64(d: Decimal) -> f64 { + d.round_dp(10).to_string().parse().unwrap_or(0.0) +} \ No newline at end of file diff --git a/libs/agent/chat/chat_execution.rs b/libs/agent/chat/chat_execution.rs new file mode 100644 index 0000000..1beb51c --- /dev/null +++ b/libs/agent/chat/chat_execution.rs @@ -0,0 +1,357 @@ +use std::pin::Pin; +use std::sync::Arc; +use uuid::Uuid; + +use crate::client::AiClientConfig; +use crate::client::types::{ChatRequestMessage, ToolCall}; +use crate::client::{StreamChunk, StreamChunkType, StreamedToolCall, call_stream}; +use crate::error::Result; +use crate::tool::{ToolCall as AgentToolCall, ToolContext, ToolDefinition, ToolExecutor, ToolHandler, ToolParam}; +use crate::tool::registry::ToolRegistry; +use crate::embed::EmbedService; + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; + +use super::{AiChunkType, AiStreamChunk, StreamCallback}; +use super::service::StreamResult; + +// Keyword-extraction-based title generator: reads conversation messages, extracts +// meaningful words, and updates the conversation record with a short title. +async fn generate_title_for_conversation( + ctx: &ToolContext, + conversation_id: Uuid, +) -> Result { + use models::ai::{ai_conversation, ai_message, AiMessage}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; + + let db_reader = ctx.db().reader(); + let db_writer = ctx.db().writer(); + let conv = ai_conversation::Entity::find_by_id(conversation_id) + .one(db_reader) + .await + .map_err(|e| crate::error::AgentError::ToolExecutionFailed { tool: "generate_title".into(), cause: format!("db error: {}", e) })? + .ok_or_else(|| crate::error::AgentError::NotFound("Conversation not found".into()))?; + + let recent_messages = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::Role.eq("user")) + .order_by_desc(ai_message::Column::CreatedAt) + .limit(3) + .all(db_reader) + .await + .map_err(|e| crate::error::AgentError::ToolExecutionFailed { tool: "generate_title".into(), cause: format!("db error: {}", e) })?; + + if recent_messages.is_empty() { + return Err(crate::error::AgentError::ToolExecutionFailed { tool: "generate_title".into(), cause: "No user messages found".into() }); + } + + let content = recent_messages + .first() + .and_then(|m| m.content.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or(""); + + let words: Vec<&str> = content + .split_whitespace() + .filter(|w| w.len() > 2 && !is_stop_word(w)) + .take(5) + .collect(); + + let title = if words.is_empty() { + "New Chat".to_string() + } else { + words.join(" ") + }; + + let mut active: ai_conversation::ActiveModel = conv.into(); + active.title = Set(Some(title.clone())); + active.updated_at = Set(chrono::Utc::now()); + active + .update(db_writer) + .await + .map_err(|e| crate::error::AgentError::ToolExecutionFailed { tool: "generate_title".into(), cause: format!("failed to update title: {}", e) })?; + + Ok(serde_json::json!({ "conversation_id": conversation_id.to_string(), "title": title })) +} + +fn is_stop_word(w: &str) -> bool { + matches!( + w.to_lowercase().as_str(), + "the" | "this" | "that" | "what" | "which" | "when" | "where" + | "why" | "how" | "can" | "could" | "would" | "should" + | "please" | "help" | "thanks" | "thank" | "you" | "your" + | "have" | "has" | "had" | "with" | "for" | "from" | "into" + | "about" | "also" | "just" | "now" | "very" | "really" + ) +} + +type SharedCallback = Arc Pin + Send>> + Send + Sync>; + +/// Simplified ReAct execution for Chat API. +/// +/// Unlike `execute_process_stream` (which requires `AiRequest` with room-specific data), +/// this function takes messages and tools directly. It does NOT record AI sessions to +/// the `ai_session` table — the caller is responsible for persisting results. +pub async fn execute_chat_stream( + messages: Vec, + tools: Vec, + model_name: &str, + config: &AiClientConfig, + temperature: f32, + max_tokens: u32, + max_tool_depth: usize, + tool_registry: Option<&ToolRegistry>, + db: db::database::AppDatabase, + cache: db::cache::AppCache, + app_config: config::AppConfig, + project_id: Uuid, + sender_uid: Uuid, + embed_service: Option, + on_chunk: StreamCallback, + conversation_id: Option, +) -> Result { + let on_chunk: SharedCallback = Arc::from(on_chunk); + let tools_enabled = !tools.is_empty(); + let mut messages = messages; + let mut tool_depth = 0; + let mut total_input_tokens = 0i64; + let mut total_output_tokens = 0i64; + let mut full_content = String::new(); + let mut all_chunks: Vec = Vec::new(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Conditionally inject chat_generate_title tool if conversation has no title + let (tools, _tools_injected) = if let Some(conv_id) = conversation_id { + if let Some(registry) = tool_registry { + let db_reader = db.reader(); + let has_title = models::ai::ai_conversation::Entity::find_by_id(conv_id) + .one(db_reader) + .await + .map(|c| c.map(|m| m.title.is_some()).unwrap_or(false)) + .unwrap_or(false); + if !has_title { + let mut reg = registry.clone(); + reg.register( + ToolDefinition::new("chat_generate_title") + .description( + "Generate a concise title (5 words or fewer) for the current conversation \ + based on its message history, and save it to the conversation record. \ + Call this tool at the start of a new conversation if it has no title.", + ) + .parameters(crate::tool::ToolSchema { + schema_type: "object".into(), + properties: Some({ + let mut p = std::collections::HashMap::new(); + p.insert("conversation_id".into(), ToolParam { + name: "conversation_id".into(), + param_type: "string".into(), + description: Some("The UUID of the conversation (required).".into()), + required: true, + properties: None, + items: None, + }); + p + }), + required: Some(vec!["conversation_id".into()]), + }), + ToolHandler::new(|ctx, args| { + let conv_id = args.get("conversation_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + Box::pin(async move { + match conv_id { + Some(id) => generate_title_for_conversation(&ctx, id).await + .map_err(|e| crate::tool::ToolError::ExecutionError(e.to_string())), + None => Err(crate::tool::ToolError::ExecutionError("conversation_id missing".into())), + } + }) + }), + ); + // Prepend system message instructing the model to generate title first + messages.insert(0, ChatRequestMessage::system( + "IMPORTANT: If the conversation has no title, you MUST call chat_generate_title \ + with the conversation_id immediately before answering any user question. \ + The title must be 5 words or fewer and should summarize the user's intent.".to_string(), + )); + (reg.to_openai_tools(), true) + } else { + (tools.clone(), false) + } + } else { + (tools.clone(), false) + } + } else { + (tools.clone(), false) + }; + + loop { + let on_chunk_cb = on_chunk.clone(); + let on_chunk_cb2 = on_chunk.clone(); + let tx_arc = Arc::new(tx.clone()); + let tx_arc2 = tx_arc.clone(); + + let response = call_stream( + &messages, model_name, config, temperature, max_tokens, + if tools_enabled { Some(&tools) } else { None }, None, + Arc::new(move |delta| { + let content = delta.to_string().replace('\n', ""); + let fut = on_chunk_cb(AiStreamChunk { content, done: false, chunk_type: AiChunkType::Answer }); + fut + }), + Arc::new(move |delta| { + let fut = on_chunk_cb2(AiStreamChunk { content: delta.to_string(), done: false, chunk_type: AiChunkType::Thinking }); + fut + }), + Arc::new(move |tc: &StreamedToolCall| { + let tx = tx_arc2.clone(); + let tc_owned = tc.clone(); + Box::pin(async move { let _ = tx.send(tc_owned); }) as Pin + Send>> + }), + ).await?; + + total_input_tokens += response.input_tokens; + total_output_tokens += response.output_tokens; + all_chunks.extend(response.chunks.clone()); + + let has_tool_calls = tools_enabled && !response.tool_calls.is_empty(); + if !has_tool_calls { + let final_content = response.content.clone(); + // Don't broadcast the done chunk via SSE/NATS — incremental deltas + // already delivered the content; the separate "done" SSE event + // signals completion. Pushing full content again would duplicate it + // in the frontend streaming store. + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::Answer, content: final_content.clone() }); + return Ok(StreamResult { + content: final_content, + reasoning_content: response.reasoning_content, + input_tokens: total_input_tokens, + output_tokens: total_output_tokens, + chunks: all_chunks, + }); + } + + full_content.push_str(&response.content); + + let tool_calls: Vec = response.tool_calls.iter().map(|tc| ToolCall { + id: tc.id.clone(), type_: "function".into(), + function: crate::client::types::ToolCallFunction { name: tc.name.clone(), arguments: tc.arguments.clone() }, + }).collect(); + + messages.push(ChatRequestMessage::assistant(Some(response.content.clone()), Some(tool_calls.clone()))); + + // Drain tool call notifications + loop { + match rx.try_recv() { + Ok(tc) => { + let args_display = if tc.arguments.len() > 100 { + let end = tc.arguments.char_indices().map(|(i, _)| i).take_while(|&i| i <= 100).last().unwrap_or(100); + format!("{}...", &tc.arguments[..end]) + } else { tc.arguments.clone() }; + let tool_display = format!("🔧 {}({})", tc.name, args_display); + on_chunk(AiStreamChunk { content: tool_display.clone(), done: false, chunk_type: AiChunkType::ToolCall }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: tool_display }); + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + + let calls: Vec = response.tool_calls.iter().map(|tc| AgentToolCall { + id: tc.id.clone(), name: tc.name.clone(), arguments: tc.arguments.clone(), + }).collect(); + + let tool_messages = execute_tools( + &calls, &db, &cache, &app_config, project_id, sender_uid, + tool_registry, embed_service.as_ref(), &on_chunk, &mut all_chunks, + ).await; + + messages.extend(tool_messages); + + tool_depth += 1; + if tool_depth >= max_tool_depth { + let max_depth_text = format!("[AI reached maximum tool depth ({}) — no final answer produced]", max_tool_depth); + on_chunk(AiStreamChunk { content: max_depth_text.clone(), done: true, chunk_type: AiChunkType::Answer }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::Answer, content: max_depth_text }); + return Ok(StreamResult { content: full_content, reasoning_content: String::new(), input_tokens: 0, output_tokens: 0, chunks: all_chunks }); + } + } +} + +async fn execute_tools( + calls: &[AgentToolCall], + db: &db::database::AppDatabase, + cache: &db::cache::AppCache, + app_config: &config::AppConfig, + project_id: Uuid, + sender_uid: Uuid, + tool_registry: Option<&ToolRegistry>, + embed_service: Option<&EmbedService>, + on_chunk: &SharedCallback, + all_chunks: &mut Vec, +) -> Vec { + let mut tool_messages = Vec::new(); + let mut ctx = ToolContext::new(db.clone(), cache.clone(), app_config.clone(), Uuid::nil(), Some(sender_uid)) + .with_project(project_id); + if let Some(es) = embed_service { + ctx = ctx.with_embed_service(es.clone()); + } + if let Some(registry) = tool_registry { + ctx.registry_mut().merge(registry.clone()); + } + + let mut join_set = tokio::task::JoinSet::new(); + for call in calls { + let call_clone = call.clone(); + let mut ctx_clone = ctx.clone(); + join_set.spawn(async move { + let executor = ToolExecutor::new(); + let res = executor.execute_batch(vec![call_clone.clone()], &mut ctx_clone).await; + (call_clone, res) + }); + } + + let heartbeat_dur = std::time::Duration::from_secs(10); + while !join_set.is_empty() { + tokio::select! { + Some(res) = join_set.join_next() => { + if let Ok((call, results)) = res { + match results { + Ok(results) => { + for result in &results { + let preview = match &result.result { + crate::tool::ToolResult::Ok(v) => { + let t = v.to_string(); + if t.len() > 300 { + let end = t.char_indices().map(|(i, _)| i).take_while(|&i| i <= 300).last().unwrap_or(300); + format!("{}...", &t[..end]) + } else { t.clone() } + } + crate::tool::ToolResult::Error(msg) => msg.clone(), + }; + tracing::debug!("tool_result: {} — {}", call.name, preview); + } + let success_display = format!("✅ {}", call.name); + on_chunk(AiStreamChunk { content: success_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: success_display }); + let msgs = ToolExecutor::to_tool_messages(&results); + tool_messages.extend(msgs); + } + Err(e) => { + tracing::warn!(tool = %call.name, args = %call.arguments, error = %e, "tool_call_failed"); + let err_text = format!("[Tool call failed: {}]", e); + let err_display = format!("❌ {} (failed)", call.name); + on_chunk(AiStreamChunk { content: err_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: err_display }); + tool_messages.push(ChatRequestMessage::tool(&call.id, &err_text)); + } + } + } + }, + _ = tokio::time::sleep(heartbeat_dur) => { + on_chunk(AiStreamChunk { content: String::new(), done: false, chunk_type: AiChunkType::ToolCall }).await; + } + } + } + tool_messages +} diff --git a/libs/agent/chat/message_builder.rs b/libs/agent/chat/message_builder.rs index 3b33e39..a40e747 100644 --- a/libs/agent/chat/message_builder.rs +++ b/libs/agent/chat/message_builder.rs @@ -4,7 +4,7 @@ use sea_orm::*; use super::context::RoomMessageContext; use super::{AiRequest, Mention}; use crate::client::types::ChatRequestMessage; -use crate::compact::{CompactConfig, CompactService}; +use crate::compact::CompactService; use crate::embed::EmbedService; use crate::error::Result; use crate::perception::{PerceptionService, SkillEntry}; @@ -55,7 +55,6 @@ impl MessageBuilder { let mut processed_history = Vec::new(); if let Some(compact_service) = &self.compact_service { let compact_cache_key = format!("ai:compact:{}", request.room.id); - let compact_config = CompactConfig::default(); let cached_summary: Option = match request.cache.conn().await { Ok(mut conn) => redis::cmd("GET").arg(&compact_cache_key).query_async::>(&mut conn).await.unwrap_or(None), Err(e) => { tracing::warn!(error = %e, "compact cache: conn failed"); None } @@ -71,7 +70,22 @@ impl MessageBuilder { } if processed_history.is_empty() { - match compact_service.compact_room_auto(request.room.id, Some(request.user_names.clone()), compact_config).await { + let compact_config = request.context_setting.as_ref() + .map(|s| crate::compact::CompactConfig::from_project_setting( + s.context_window_tokens, + s.compaction_threshold, + s.compaction_max_summary_ratio, + )) + .unwrap_or_default(); + + match compact_service.compact_room( + request.room.id, + compact_config.default_level, + Some(request.user_names.clone()), + request.sender.uid, + request.context_setting.as_ref().map(|s| s.context_window_tokens).unwrap_or(128000), + request.context_setting.as_ref().map(|s| s.compaction_max_summary_ratio).unwrap_or(0.2), + ).await { Ok(compact_summary) => { if !compact_summary.summary.is_empty() { messages.push(ChatRequestMessage::system(format!("Conversation summary:\n{}", compact_summary.summary))); @@ -174,7 +188,13 @@ impl MessageBuilder { let keyword_skills = self.perception_service.inject_skills(&request.input, &history_texts, &[], &all_skills).await; let mut vector_skills = Vec::new(); if let Some(es) = &self.embed_service { - vector_skills = crate::perception::VectorActiveAwareness::default().detect(es, &request.input, &request.project.id.to_string()).await; + let rag_enabled = request.context_setting.as_ref().map(|s| s.rag_enabled).unwrap_or(true); + if rag_enabled { + let max_results = request.context_setting.as_ref().map(|s| s.rag_max_results as usize).unwrap_or(3); + let min_score = request.context_setting.as_ref().map(|s| s.rag_min_score).unwrap_or(0.70); + let awareness = crate::perception::VectorActiveAwareness::new(max_results, min_score); + vector_skills = awareness.detect(es, &request.input, &request.project.id.to_string()).await; + } } let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); @@ -184,8 +204,17 @@ impl MessageBuilder { } async fn build_memory_context(&self, request: &AiRequest) -> Vec { + let rag_enabled = request.context_setting.as_ref().map(|s| s.rag_enabled).unwrap_or(true); + if !rag_enabled { + return Vec::new(); + } match &self.embed_service { - Some(es) => crate::perception::VectorPassiveAwareness::default().detect(es, &request.input, &request.project.display_name, &request.room.id.to_string()).await, + Some(es) => { + let max_results = request.context_setting.as_ref().map(|s| s.rag_max_results as usize).unwrap_or(3); + let min_score = request.context_setting.as_ref().map(|s| s.rag_min_score).unwrap_or(0.72); + let awareness = crate::perception::VectorPassiveAwareness::new(max_results, min_score); + awareness.detect(es, &request.input, &request.project.display_name, &request.room.id.to_string()).await + } None => Vec::new(), } } diff --git a/libs/agent/chat/mod.rs b/libs/agent/chat/mod.rs index 67f150d..5ecd3d7 100644 --- a/libs/agent/chat/mod.rs +++ b/libs/agent/chat/mod.rs @@ -3,7 +3,7 @@ use std::pin::Pin; use db::cache::AppCache; use db::database::AppDatabase; use models::agents::model; -use models::projects::project; +use models::projects::{project, project_context_setting}; use models::repos::repo; use models::rooms::{room, room_message}; use models::users::user; @@ -44,7 +44,32 @@ impl Default for AiChunkType { } } -/// Optional streaming callback: called for each token chunk. +const THINK_OPEN: &str = "\x3cthinking\x3e"; +const THINK_CLOSE: &str = "\x3c/response\x3e"; + +/// Strip XML-format thinking tags that some models (e.g. DeepSeek-R1) embed +/// in reasoning output. Also normalizes excessive consecutive newlines (3+ → 2). +pub fn normalize_thinking_content(content: &str) -> String { + let content = content + .replace(THINK_CLOSE, "") + .replace(THINK_OPEN, "") + .replace("\x3cthinking", "") + .replace("/response\x3e", ""); + let mut result = String::with_capacity(content.len()); + let mut newline_count = 0usize; + for ch in content.chars() { + if ch == '\n' { + newline_count += 1; + if newline_count <= 2 { + result.push(ch); + } + } else { + newline_count = 0; + result.push(ch); + } + } + result.trim().to_string() +} pub type StreamCallback = Box< dyn Fn(AiStreamChunk) -> Pin + Send>> + Send + Sync, >; @@ -55,6 +80,7 @@ pub struct AiRequest { pub config: AppConfig, pub model: model::Model, pub project: project::Model, + pub context_setting: Option, pub sender: user::Model, pub room: room::Model, pub input: String, @@ -76,6 +102,7 @@ pub enum Mention { Repo(repo::Model), } +pub mod chat_execution; pub mod context; pub mod message_builder; pub mod nonstreaming_execution; diff --git a/libs/agent/chat/nonstreaming_execution.rs b/libs/agent/chat/nonstreaming_execution.rs index 292d38c..1e8cd7e 100644 --- a/libs/agent/chat/nonstreaming_execution.rs +++ b/libs/agent/chat/nonstreaming_execution.rs @@ -82,13 +82,13 @@ pub async fn execute_process( tool_depth += 1; if tool_depth >= max_tool_depth { let content = if text.is_empty() { format!("[AI reached maximum tool depth ({}) — no final answer produced]", max_tool_depth) } else { text }; - record_ai_session(&request.cache, &request.db, request.project.id, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), input_tokens, output_tokens, session_start.elapsed().as_millis() as i64).await; + record_ai_session(&request.cache, &request.db, request.project.id, request.sender.uid, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), input_tokens, output_tokens, session_start.elapsed().as_millis() as i64).await; return Ok(ProcessResult { content, input_tokens, output_tokens }); } continue; } - record_ai_session(&request.cache, &request.db, request.project.id, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), input_tokens, output_tokens, session_start.elapsed().as_millis() as i64).await; + record_ai_session(&request.cache, &request.db, request.project.id, request.sender.uid, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), input_tokens, output_tokens, session_start.elapsed().as_millis() as i64).await; return Ok(ProcessResult { content: text, input_tokens, output_tokens }); } } @@ -111,7 +111,7 @@ async fn execute_tools( let elapsed = start.elapsed().as_millis() as i64; let is_error = matches!(result.result, crate::tool::ToolResult::Error(_)); let error_msg = match &result.result { crate::tool::ToolResult::Error(msg) => Some(msg.clone()), _ => None }; - recorder.record(crate::tool::recorder::ToolCallRecord { tool_call_id: call.clone(), session_id: recorder.session_id(), tool_name: call.clone(), caller: request.sender.uid, arguments: serde_json::Value::Null, status: if is_error { models::ai::ToolCallStatus::Failed } else { models::ai::ToolCallStatus::Success }, execution_time_ms: Some(elapsed), error_message: error_msg, error_stack: None, retry_count: 0 }); + recorder.record(crate::tool::recorder::ToolCallRecord { tool_call_id: Uuid::new_v4().to_string(), session_id: recorder.session_id(), tool_name: call.clone(), caller: request.sender.uid, arguments: serde_json::Value::Null, status: if is_error { models::ai::ToolCallStatus::Failed } else { models::ai::ToolCallStatus::Success }, execution_time_ms: Some(elapsed), error_message: error_msg, error_stack: None, retry_count: 0 }); } crate::tool::ToolExecutor::to_tool_messages(&results) } diff --git a/libs/agent/chat/react_execution.rs b/libs/agent/chat/react_execution.rs index 026d0e0..e94965f 100644 --- a/libs/agent/chat/react_execution.rs +++ b/libs/agent/chat/react_execution.rs @@ -18,6 +18,8 @@ pub async fn execute_process_react( request: &AiRequest, mut on_chunk: C, tool_registry: &ToolRegistry, ai_base_url: Option, ai_api_key: Option, + room_preamble: Option<&str>, + message_producer: Option, ) -> Result<(String, i64, i64)> where C: FnMut(ReactStep) -> Fut + Send, @@ -33,6 +35,9 @@ where let room_id = request.room.id; let sender_uid = request.sender.uid; let project_id = request.project.id; + let ai_model_id = request.model.id; + let ai_model_name = request.model.name.clone(); + let sent_in_turn = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let session_id = Uuid::now_v7(); let session_start = std::time::Instant::now(); let version_id = room_ai::Entity::find() @@ -46,7 +51,9 @@ where if let Some(handler) = tool_registry.get(&name) { let adapter = crate::tool::RigToolAdapter::new( handler.clone(), def.clone(), db.clone(), cache.clone(), cfg.clone(), - room_id, Some(sender_uid), project_id, + room_id, Some(sender_uid), project_id, message_producer.clone(), + Some(ai_model_id), Some(ai_model_name.clone()), + sent_in_turn.clone(), ); tools.push(Box::new(RecordingTool::new(Box::new(adapter), db.clone(), session_id, sender_uid))); } @@ -54,8 +61,14 @@ where let rig_client = client_config.build_rig_client(); let model = rig_client.completion_model(&request.model.name); + + let preamble = match room_preamble { + Some(rp) => format!("{}\n{}", rp, DEFAULT_SYSTEM_PROMPT), + None => DEFAULT_SYSTEM_PROMPT.to_string(), + }; + let agent = AgentBuilder::new(model) - .preamble(DEFAULT_SYSTEM_PROMPT) + .preamble(&preamble) .tools(tools) .default_max_turns(request.max_tool_depth) .build(); @@ -77,7 +90,8 @@ where Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Text(text))) => { step_count += 1; let t = text.text; - on_chunk(ReactStep::Answer { step: step_count, answer: t.clone() }).await; + let cleaned = t.replace('\n', ""); + on_chunk(ReactStep::Answer { step: step_count, answer: cleaned }).await; final_content.push_str(&t); } Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning(reasoning))) => { @@ -120,7 +134,7 @@ where } let elapsed_ms = session_start.elapsed().as_millis() as i64; - record_ai_session(&request.cache, &request.db, request.project.id, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, elapsed_ms).await; + record_ai_session(&request.cache, &request.db, request.project.id, request.sender.uid, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, elapsed_ms).await; Ok((final_content, total_input_tokens, total_output_tokens)) } diff --git a/libs/agent/chat/service.rs b/libs/agent/chat/service.rs index 1d8e59c..179ebf3 100644 --- a/libs/agent/chat/service.rs +++ b/libs/agent/chat/service.rs @@ -7,6 +7,7 @@ use crate::embed::EmbedService; use crate::error::Result; use crate::perception::PerceptionService; use crate::tool::registry::ToolRegistry; +use queue::MessageProducer; /// Result from streaming AI response. pub struct StreamResult { @@ -94,7 +95,8 @@ impl ChatService { ) -> Option { self.tool_registry.as_ref().map(|registry| { crate::RigToolSet::from_registry( - registry, db, cache, config, room_id, sender_id, project_id, + registry, db, cache, config, room_id, sender_id, project_id, None, None, None, + std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), ) }) } @@ -134,6 +136,35 @@ impl ChatService { super::react_execution::execute_process_react( request, on_chunk, registry, self.ai_base_url.clone(), self.ai_api_key.clone(), + None, None, + ).await + } + + /// Process AI request via rig-based ReAct streaming loop with room-specific tools. + /// + /// Merges `room_tools` (e.g. `send_message`, `retract_message`) into the base + /// tool registry on-the-fly. The `room_preamble` is prepended to the default + /// system prompt to instruct the AI about room communication rules. + /// `message_producer` enables tools to publish events via the message queue. + pub async fn process_react_room( + &self, request: &AiRequest, on_chunk: C, + room_tools: ToolRegistry, + room_preamble: Option<&str>, + message_producer: Option, + ) -> Result<(String, i64, i64)> + where + C: FnMut(crate::react::ReactStep) -> Fut + Send, + Fut: std::future::Future + Send, + { + let Some(registry) = &self.tool_registry else { + return Err(crate::error::AgentError::Internal("no tool registry registered".into())); + }; + let mut merged = registry.clone(); + merged.merge(room_tools); + super::react_execution::execute_process_react( + request, on_chunk, &merged, + self.ai_base_url.clone(), self.ai_api_key.clone(), + room_preamble, message_producer, ).await } } diff --git a/libs/agent/chat/session_recording.rs b/libs/agent/chat/session_recording.rs index c9ebfae..d45d720 100644 --- a/libs/agent/chat/session_recording.rs +++ b/libs/agent/chat/session_recording.rs @@ -8,6 +8,7 @@ pub async fn record_ai_session( cache: &AppCache, db: &AppDatabase, project_id: Uuid, + user_id: Uuid, session_id: Uuid, room_id: Uuid, model_id: Uuid, @@ -39,7 +40,7 @@ pub async fn record_ai_session( } let (cost, currency, error_msg) = match crate::billing::record_ai_usage( - db, project_id, version_id, input_tokens, output_tokens, + db, project_id, user_id, version_id, input_tokens, output_tokens, ).await { Ok(crate::billing::BillingResult::Success(record)) => { (Some(record.cost), Some(record.currency), None) @@ -70,7 +71,7 @@ async fn create_billing_error_system_message( use models::rooms::{room_message, MessageContentType, MessageSenderType}; use sea_orm::Set; - let seq_key = format!("room:seq:{}", room_id); + let seq_key = format!("seq:room:{}", room_id); let seq = match cache.conn().await { Ok(mut conn) => { match redis::cmd("INCR").arg(&seq_key).query_async::(&mut conn).await { diff --git a/libs/agent/chat/streaming_execution.rs b/libs/agent/chat/streaming_execution.rs index 498bfe7..2d1d2d8 100644 --- a/libs/agent/chat/streaming_execution.rs +++ b/libs/agent/chat/streaming_execution.rs @@ -62,7 +62,8 @@ pub async fn execute_process_stream( &messages, &model_name, &config, temperature, max_tokens, if tools_enabled { Some(&tools) } else { None }, None, Arc::new(move |delta| { - let fut = on_chunk_cb(AiStreamChunk { content: delta.to_string(), done: false, chunk_type: AiChunkType::Answer }); + let content = delta.to_string().replace('\n', ""); + let fut = on_chunk_cb(AiStreamChunk { content, done: false, chunk_type: AiChunkType::Answer }); fut }), Arc::new(move |delta| { @@ -82,11 +83,10 @@ pub async fn execute_process_stream( let has_tool_calls = tools_enabled && !response.tool_calls.is_empty(); if !has_tool_calls { - return handle_final_answer(response, full_content, on_chunk, all_chunks, &request, session_id, version_id, total_input_tokens, total_output_tokens, session_start).await; + return handle_final_answer(response, all_chunks, &request, session_id, version_id, total_input_tokens, total_output_tokens, session_start).await; } full_content.push_str(&response.content); - full_content.push('\n'); let tool_calls: Vec = response.tool_calls.iter().map(|tc| ToolCall { id: tc.id.clone(), type_: "function".into(), @@ -114,7 +114,7 @@ pub async fn execute_process_stream( let max_depth_text = format!("[AI reached maximum tool depth ({}) — no final answer produced]", max_tool_depth); on_chunk(AiStreamChunk { content: max_depth_text.clone(), done: true, chunk_type: AiChunkType::Answer }).await; all_chunks.push(StreamChunk { chunk_type: StreamChunkType::Answer, content: max_depth_text }); - record_ai_session(&request.cache, &request.db, request.project.id, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, session_start.elapsed().as_millis() as i64).await; + record_ai_session(&request.cache, &request.db, request.project.id, request.sender.uid, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, session_start.elapsed().as_millis() as i64).await; return Ok(StreamResult { content: full_content, reasoning_content: String::new(), input_tokens: 0, output_tokens: 0, chunks: all_chunks }); } } @@ -155,60 +155,83 @@ async fn execute_streaming_tools( if let Some(registry) = tool_registry { ctx.registry_mut().merge(registry.clone()); } let recorder = crate::tool::recorder::ToolCallRecorder::with_session(request.db.clone(), session_id); + let mut join_set = tokio::task::JoinSet::new(); for call in calls { - let start = std::time::Instant::now(); let call_clone = call.clone(); let mut ctx_clone = ctx.clone(); - let (result_tx, mut result_rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { + let sender_uid = request.sender.uid; + let recorder_clone = recorder.clone(); + + join_set.spawn(async move { + let start = std::time::Instant::now(); let executor = ToolExecutor::new(); - let res = executor.execute_batch(vec![call_clone], &mut ctx_clone).await; - let _ = result_tx.send(res); + let res = executor.execute_batch(vec![call_clone.clone()], &mut ctx_clone).await; + (call_clone, res, start.elapsed(), sender_uid, recorder_clone) }); + } - let heartbeat_dur = std::time::Duration::from_secs(10); - let results = loop { - tokio::select! { - res = &mut result_rx => { - match res { Ok(inner) => break inner, Err(_) => break Err(crate::tool::ToolError::ExecutionError("tool task cancelled".into())), } - }, - _ = tokio::time::sleep(heartbeat_dur) => { - on_chunk(AiStreamChunk { content: String::new(), done: false, chunk_type: AiChunkType::ToolCall }).await; + let heartbeat_dur = std::time::Duration::from_secs(10); + while !join_set.is_empty() { + tokio::select! { + Some(res) = join_set.join_next() => { + if let Ok((call, results, elapsed, sender_uid, recorder)) = res { + match results { + Ok(results) => { + for result in &results { + let text = match &result.result { crate::tool::ToolResult::Ok(v) => v.to_string(), crate::tool::ToolResult::Error(msg) => msg.clone() }; + let preview = if text.len() > 300 { + let end = text.char_indices().map(|(i, _)| i).take_while(|&i| i <= 300).last().unwrap_or(300); + format!("{}...", &text[..end]) + } else { text.clone() }; + tracing::debug!("tool_result: {} — {}", call.name, preview); + + let is_error = matches!(result.result, crate::tool::ToolResult::Error(_)); + let error_msg = match &result.result { crate::tool::ToolResult::Error(msg) => Some(msg.clone()), _ => None }; + recorder.record(crate::tool::recorder::ToolCallRecord { + tool_call_id: call.id.clone(), + session_id: recorder.session_id(), + tool_name: call.name.clone(), + caller: sender_uid, + arguments: call.arguments_json().unwrap_or_default(), + status: if is_error { models::ai::ToolCallStatus::Failed } else { models::ai::ToolCallStatus::Success }, + execution_time_ms: Some(elapsed.as_millis() as i64), + error_message: error_msg, + error_stack: None, + retry_count: 0 + }); + } + let success_display = format!("✅ {}", call.name); + on_chunk(AiStreamChunk { content: success_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: success_display }); + let msgs = ToolExecutor::to_tool_messages(&results); + tool_messages.extend(msgs); + } + Err(e) => { + recorder.record(crate::tool::recorder::ToolCallRecord { + tool_call_id: call.id.clone(), + session_id: recorder.session_id(), + tool_name: call.name.clone(), + caller: sender_uid, + arguments: call.arguments_json().unwrap_or_default(), + status: models::ai::ToolCallStatus::Failed, + execution_time_ms: Some(elapsed.as_millis() as i64), + error_message: Some(e.to_string()), + error_stack: None, + retry_count: 0 + }); + let err_text = format!("[Tool call failed: {}]", e); + tracing::warn!(tool = %call.name, args = %call.arguments, error = %e, "tool_call_failed"); + let err_display = format!("❌ {} (failed)", call.name); + on_chunk(AiStreamChunk { content: err_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; + all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: err_display }); + tool_messages.push(ChatRequestMessage::tool(&call.id, &err_text)); + } + } } - } - }; - - match results { - Ok(results) => { - for result in &results { - let text = match &result.result { crate::tool::ToolResult::Ok(v) => v.to_string(), crate::tool::ToolResult::Error(msg) => msg.clone() }; - let preview = if text.len() > 300 { - let end = text.char_indices().map(|(i, _)| i).take_while(|&i| i <= 300).last().unwrap_or(300); - format!("{}...", &text[..end]) - } else { text.clone() }; - tracing::debug!("tool_result: {} — {}", call.name, preview); - - let elapsed = start.elapsed().as_millis() as i64; - let is_error = matches!(result.result, crate::tool::ToolResult::Error(_)); - let error_msg = match &result.result { crate::tool::ToolResult::Error(msg) => Some(msg.clone()), _ => None }; - recorder.record(crate::tool::recorder::ToolCallRecord { tool_call_id: call.id.clone(), session_id: recorder.session_id(), tool_name: call.name.clone(), caller: request.sender.uid, arguments: call.arguments_json().unwrap_or_default(), status: if is_error { models::ai::ToolCallStatus::Failed } else { models::ai::ToolCallStatus::Success }, execution_time_ms: Some(elapsed), error_message: error_msg, error_stack: None, retry_count: 0 }); - } - let success_display = format!("✅ {}", call.name); - on_chunk(AiStreamChunk { content: success_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; - all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: success_display }); - let msgs = ToolExecutor::to_tool_messages(&results); - tool_messages.extend(msgs); - } - Err(e) => { - let elapsed = start.elapsed().as_millis() as i64; - recorder.record(crate::tool::recorder::ToolCallRecord { tool_call_id: call.id.clone(), session_id: recorder.session_id(), tool_name: call.name.clone(), caller: request.sender.uid, arguments: call.arguments_json().unwrap_or_default(), status: models::ai::ToolCallStatus::Failed, execution_time_ms: Some(elapsed), error_message: Some(e.to_string()), error_stack: None, retry_count: 0 }); - let err_text = format!("[Tool call failed: {}]", e); - tracing::warn!(tool = %call.name, args = %call.arguments, error = %e, "tool_call_failed"); - let err_display = format!("❌ {} (failed)", call.name); - on_chunk(AiStreamChunk { content: err_display.clone(), done: false, chunk_type: AiChunkType::ToolResult }).await; - all_chunks.push(StreamChunk { chunk_type: StreamChunkType::ToolCall, content: err_display }); - tool_messages.push(ChatRequestMessage::tool(&call.id, &err_text)); + }, + _ = tokio::time::sleep(heartbeat_dur) => { + on_chunk(AiStreamChunk { content: String::new(), done: false, chunk_type: AiChunkType::ToolCall }).await; } } } @@ -216,18 +239,20 @@ async fn execute_streaming_tools( } async fn handle_final_answer( - response: crate::client::StreamResponse, full_content: String, - on_chunk: SharedCallback, + response: crate::client::StreamResponse, mut all_chunks: Vec, request: &AiRequest, session_id: Uuid, version_id: Option, total_input_tokens: i64, total_output_tokens: i64, session_start: std::time::Instant, ) -> Result { - let full_content = full_content + &response.content; - on_chunk(AiStreamChunk { content: response.content.clone(), done: true, chunk_type: AiChunkType::Answer }).await; + let full_content = response.content.clone(); + // Don't broadcast the done chunk via SSE/NATS — incremental deltas + // already delivered the content; the separate completion event + // signals end of stream. Broadcasting full content again would + // duplicate it in the frontend streaming display. all_chunks.push(StreamChunk { chunk_type: StreamChunkType::Answer, content: response.content.clone() }); - record_ai_session(&request.cache, &request.db, request.project.id, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, session_start.elapsed().as_millis() as i64).await; - Ok(StreamResult { content: full_content, reasoning_content: response.reasoning_content, input_tokens: response.input_tokens, output_tokens: response.output_tokens, chunks: all_chunks }) + record_ai_session(&request.cache, &request.db, request.project.id, request.sender.uid, session_id, request.room.id, request.model.id, version_id.unwrap_or_default(), total_input_tokens, total_output_tokens, session_start.elapsed().as_millis() as i64).await; + Ok(StreamResult { content: full_content, reasoning_content: response.reasoning_content, input_tokens: total_input_tokens, output_tokens: total_output_tokens, chunks: all_chunks }) } async fn inject_passive_skills_stream( diff --git a/libs/agent/client/mod.rs b/libs/agent/client/mod.rs index 02849ce..415f054 100644 --- a/libs/agent/client/mod.rs +++ b/libs/agent/client/mod.rs @@ -106,8 +106,10 @@ impl RetryState { fn backoff_duration(&self) -> std::time::Duration { let exp = self.attempt.min(5); let base_ms = 500u64.saturating_mul(2u64.pow(exp)).min(self.max_backoff_ms); - let jitter = fastrand_u64(base_ms + 1); - std::time::Duration::from_millis(jitter) + let max_jitter = (base_ms / 2).max(base_ms); + let offset = fastrand_u64(max_jitter + 1).saturating_sub(base_ms / 2); + let total = base_ms.saturating_add(offset).min(self.max_backoff_ms); + std::time::Duration::from_millis(total) } fn next(&mut self) { self.attempt += 1; } } diff --git a/libs/agent/compact/service.rs b/libs/agent/compact/service.rs index 86d32f4..ee74b9c 100644 --- a/libs/agent/compact/service.rs +++ b/libs/agent/compact/service.rs @@ -4,18 +4,14 @@ use models::rooms::room_message::{ Column as RmCol, Entity as RoomMessage, Model as RoomMessageModel, }; use models::users::user::{Column as UserCol, Entity as User}; -use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, QueryOrder}; -use serde_json::Value; +use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use uuid::Uuid; use crate::client::types::ChatRequestMessage; use crate::client::AiClientConfig; use crate::client::call_with_params; use crate::AgentError; -use crate::compact::helpers::summary_content; -use crate::compact::types::{ - CompactConfig, CompactLevel, CompactSummary, MessageSummary, ThresholdResult, -}; +use crate::compact::types::{CompactLevel, CompactSummary, MessageSummary}; use crate::tokent::{TokenUsage, resolve_usage}; #[derive(Clone)] @@ -35,8 +31,29 @@ impl CompactService { room_id: Uuid, level: CompactLevel, user_names: Option>, + requester_id: Uuid, + context_window_tokens: i32, + compaction_max_summary_ratio: f32, ) -> Result { - let messages = self.fetch_room_messages(room_id).await?; + // Verify room access at the database level to ensure auth context is enforced. + // Public rooms are accessible to project members. + // For simplicity in this audit fix, we'll fetch only if access exists. + let messages = self.fetch_room_messages_secure(room_id, requester_id).await?; + + if messages.is_empty() { + // Check if room actually exists or if it's just empty/inaccessible + let room_exists = models::rooms::room::Entity::find_by_id(room_id) + .one(&self.db) + .await + .map_err(|e| AgentError::Internal(e.to_string()))? + .is_some(); + + if room_exists { + return Err(AgentError::Internal("Access denied or room empty".into())); + } else { + return Err(AgentError::Internal("Room not found".into())); + } + } let user_ids: Vec = messages .iter() @@ -74,7 +91,9 @@ impl CompactService { .map(|m| Self::message_to_summary(m, &user_name_map)) .collect(); - let (summary, remote_usage) = self.summarize_messages(to_summarize).await?; + let max_summary_tokens = (context_window_tokens as f32 * compaction_max_summary_ratio) as usize; + + let (summary, remote_usage) = self.summarize_messages(to_summarize, max_summary_tokens).await?; // Build text of what was summarized (for tiktoken fallback) let summarized_text = to_summarize @@ -100,10 +119,13 @@ impl CompactService { session_id: Uuid, level: CompactLevel, user_names: Option>, + context_window_tokens: i32, + compaction_max_summary_ratio: f32, ) -> Result { let messages: Vec = RoomMessage::find() .filter(RmCol::Room.eq(session_id)) .order_by_asc(RmCol::Seq) + .limit(10000) .all(&self.db) .await .map_err(|e| AgentError::Internal(e.to_string()))?; @@ -148,10 +170,10 @@ impl CompactService { .map(|m| Self::message_to_summary(m, &user_name_map)) .collect(); - // Summarize the earlier messages - let (summary, remote_usage) = self.summarize_messages(to_summarize).await?; + let max_summary_tokens = (context_window_tokens as f32 * compaction_max_summary_ratio) as usize; + + let (summary, remote_usage) = self.summarize_messages(to_summarize, max_summary_tokens).await?; - // Build text of what was summarized (for tiktoken fallback) let summarized_text = to_summarize .iter() .map(|m| m.content.as_str()) @@ -170,164 +192,51 @@ impl CompactService { }) } - pub fn summary_as_system_message(summary: &CompactSummary) -> ChatRequestMessage { - let content = summary_content(summary); - ChatRequestMessage::system(content) - } - - /// Check if the message history for a room exceeds the token threshold. - /// Returns `ThresholdResult::Skip` if below threshold, `Compact` if above. - /// - /// This method fetches messages and estimates their token count using tiktoken. - /// Call this before deciding whether to run full compaction. - pub async fn check_threshold( + async fn fetch_room_messages_secure( &self, room_id: Uuid, - config: CompactConfig, - ) -> Result { - let messages = self.fetch_room_messages(room_id).await?; - let tokens = self.estimate_message_tokens(&messages); + requester_id: Uuid, + ) -> Result, AgentError> { + use models::rooms::{RoomUserState, RoomAccess}; + use sea_orm::QueryTrait; + use sea_orm::sea_query::Expr; + + // Find messages for the room where the requester has access. + // We check both the room_user_state table (membership) and the room_access table (explicit grants). + RoomMessage::find() + .filter(RmCol::Room.eq(room_id)) + .filter( + sea_orm::Condition::any() + .add( + Expr::exists( + RoomUserState::find() + .filter(models::rooms::room_user_state::Column::Room.eq(room_id)) + .filter(models::rooms::room_user_state::Column::User.eq(requester_id)) + .into_query() + ) + ) + .add( + Expr::exists( + RoomAccess::find() + .filter(models::rooms::room_access::Column::Room.eq(room_id)) + .filter(models::rooms::room_access::Column::User.eq(requester_id)) + .into_query() + ) + ) + ) + .order_by_asc(RmCol::Seq) + .limit(10000) + .all(&self.db) + .await + .map_err(|e| AgentError::Internal(e.to_string())) + } - if tokens < config.token_threshold { - return Ok(ThresholdResult::Skip { - estimated_tokens: tokens, - }); - } - - let level = if config.auto_level { - CompactLevel::auto_select(tokens, config.token_threshold) + fn message_to_summary(m: &RoomMessageModel, user_name_map: &std::collections::HashMap) -> MessageSummary { + let sender_name = if let Some(user_id) = m.sender_id { + user_name_map.get(&user_id).cloned().unwrap_or_else(|| m.sender_type.to_string()) } else { - config.default_level + m.sender_type.to_string() }; - - Ok(ThresholdResult::Compact { - estimated_tokens: tokens, - level, - }) - } - - /// Auto-compact a room: estimates token count, only compresses if over threshold. - /// - /// This is the recommended entry point for automatic compaction. - /// - If tokens < threshold → returns a no-op summary (empty summary, no compression) - /// - If tokens >= threshold → compresses with auto-selected level - pub async fn compact_room_auto( - &self, - room_id: Uuid, - user_names: Option>, - config: CompactConfig, - ) -> Result { - let threshold_result = self.check_threshold(room_id, config).await?; - - match threshold_result { - ThresholdResult::Skip { .. } => { - // Below threshold — no compaction needed, return empty summary - let messages = self.fetch_room_messages(room_id).await?; - let user_ids: Vec = messages.iter().filter_map(|m| m.sender_id).collect(); - let user_name_map = match user_names { - Some(map) => map, - None => self.get_user_name_map(&user_ids).await?, - }; - let retained: Vec = messages - .iter() - .map(|m| Self::message_to_summary(m, &user_name_map)) - .collect(); - - return Ok(CompactSummary { - session_id: Uuid::new_v4(), - room_id, - retained, - summary: String::new(), - compacted_at: Utc::now(), - messages_compressed: 0, - usage: None, - }); - } - ThresholdResult::Compact { level, .. } => { - // Above threshold — compress with selected level - return self - .compact_room_with_level(room_id, level, user_names) - .await; - } - } - } - - /// Compact a room with a specific level (bypassing threshold check). - /// Use this when the caller has already decided compaction is needed. - async fn compact_room_with_level( - &self, - room_id: Uuid, - level: CompactLevel, - user_names: Option>, - ) -> Result { - let messages = self.fetch_room_messages(room_id).await?; - - let user_ids: Vec = messages.iter().filter_map(|m| m.sender_id).collect(); - let user_name_map = match user_names { - Some(map) => map, - None => self.get_user_name_map(&user_ids).await?, - }; - - if messages.len() <= level.retain_count() { - let retained: Vec = messages - .iter() - .map(|m| Self::message_to_summary(m, &user_name_map)) - .collect(); - return Ok(CompactSummary { - session_id: Uuid::new_v4(), - room_id, - retained, - summary: String::new(), - compacted_at: Utc::now(), - messages_compressed: 0, - usage: None, - }); - } - - let retain_count = level.retain_count(); - let split_index = messages.len().saturating_sub(retain_count); - let (to_summarize, retained_messages) = messages.split_at(split_index); - - let retained: Vec = retained_messages - .iter() - .map(|m| Self::message_to_summary(m, &user_name_map)) - .collect(); - - let (summary, remote_usage) = self.summarize_messages(to_summarize).await?; - - let summarized_text = to_summarize - .iter() - .map(|m| m.content.as_str()) - .collect::>() - .join("\n"); - let usage = resolve_usage(remote_usage, &self.model, &summarized_text, &summary); - - Ok(CompactSummary { - session_id: Uuid::new_v4(), - room_id, - retained, - summary, - compacted_at: Utc::now(), - messages_compressed: to_summarize.len(), - usage: Some(usage), - }) - } - - /// Estimate total token count of a message list using tiktoken. - fn estimate_message_tokens(&self, messages: &[RoomMessageModel]) -> usize { - let total_chars: usize = messages.iter().map(|m| m.content.len()).sum(); - // Rough estimate: ~4 chars per token (safe upper bound) - total_chars / 4 - } - - fn message_to_summary( - m: &RoomMessageModel, - user_name_map: &std::collections::HashMap, - ) -> MessageSummary { - let sender_name = m - .sender_id - .and_then(|id| user_name_map.get(&id).cloned()) - .unwrap_or_else(|| m.sender_type.to_string()); MessageSummary { id: m.id, sender_type: m.sender_type.clone(), @@ -335,35 +244,11 @@ impl CompactService { sender_name, content: m.content.clone(), content_type: m.content_type.clone(), - tool_call_id: Self::extract_tool_call_id(&m.content), + tool_call_id: None, send_at: m.send_at, } } - fn extract_tool_call_id(content: &str) -> Option { - let content = content.trim(); - if let Ok(v) = serde_json::from_str::(content) { - v.get("tool_call_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - } else { - None - } - } - - async fn fetch_room_messages( - &self, - room_id: Uuid, - ) -> Result, AgentError> { - let messages: Vec = RoomMessage::find() - .filter(RmCol::Room.eq(room_id)) - .order_by_asc(RmCol::Seq) - .all(&self.db) - .await - .map_err(|e| AgentError::Internal(e.to_string()))?; - Ok(messages) - } - async fn get_user_name_map( &self, user_ids: &[Uuid], @@ -386,8 +271,8 @@ impl CompactService { async fn summarize_messages( &self, messages: &[RoomMessageModel], + max_summary_tokens: usize, ) -> Result<(String, Option), AgentError> { - // Collect distinct user IDs let user_ids: Vec = messages .iter() .filter_map(|m| m.sender_id) @@ -395,10 +280,8 @@ impl CompactService { .into_iter() .collect(); - // Query usernames let user_name_map = self.get_user_name_map(&user_ids).await?; - // Define sender mapper let sender_mapper = |m: &RoomMessageModel| { if let Some(user_id) = m.sender_id { if let Some(username) = user_name_map.get(&user_id) { @@ -413,11 +296,13 @@ impl CompactService { let user_msg = ChatRequestMessage::user(format!( "Summarise the following conversation concisely, preserving all key facts, \ decisions, and any pending or in-progress work. \ + The summary MUST NOT exceed {} tokens. \ Use this format:\n\n\ **Summary:** \n\ **Key decisions:** \n\ **Open items:** \n\n\ Conversation:\n\n{}", + max_summary_tokens, body )); @@ -425,8 +310,8 @@ impl CompactService { &[user_msg], &self.model, &self.ai_client_config, - 0.3, // slightly higher temp for summarization - 1024, // max output tokens + 0.3, + 2048, None, None, None, @@ -434,7 +319,6 @@ impl CompactService { .await .map_err(|e| AgentError::OpenAi(e.to_string()))?; - // Prefer remote usage; fall back to None (caller will use tiktoken via resolve_usage) let remote_usage = TokenUsage::from_remote(response.input_tokens as u32, response.output_tokens as u32); diff --git a/libs/agent/compact/types.rs b/libs/agent/compact/types.rs index 05b0493..fcea428 100644 --- a/libs/agent/compact/types.rs +++ b/libs/agent/compact/types.rs @@ -74,6 +74,8 @@ pub struct CompactConfig { pub auto_level: bool, /// Fallback level when `auto_level` is false. pub default_level: CompactLevel, + /// Maximum tokens the summary may contain (enforced via prompt). + pub max_summary_tokens: usize, } impl Default for CompactConfig { @@ -83,6 +85,20 @@ impl Default for CompactConfig { token_threshold: 8000, auto_level: true, default_level: CompactLevel::Light, + max_summary_tokens: 256, + } + } +} + +impl CompactConfig { + /// Build config from project context settings. + pub fn from_project_setting(context_window_tokens: i32, compaction_threshold: f32, compaction_max_summary_ratio: f32) -> Self { + let threshold = (context_window_tokens as f32 * compaction_threshold) as usize; + Self { + token_threshold: threshold, + auto_level: true, + default_level: CompactLevel::Light, + max_summary_tokens: (context_window_tokens as f32 * compaction_max_summary_ratio) as usize, } } } diff --git a/libs/agent/embed/service.rs b/libs/agent/embed/service.rs index 8a9bf44..add80b2 100644 --- a/libs/agent/embed/service.rs +++ b/libs/agent/embed/service.rs @@ -575,11 +575,5 @@ pub struct EmbedMemoryInput { } /// Input struct for batch tag embedding. -#[derive(Debug, Clone)] -pub struct TagEmbedInput { - pub repo_id: String, - pub repo_name: String, - pub project_id: String, - pub name: String, - pub description: Option, -} +/// Re-exported from models for backward compatibility. +pub use models::TagEmbedInput; diff --git a/libs/agent/error.rs b/libs/agent/error.rs index e979b37..3f6668b 100644 --- a/libs/agent/error.rs +++ b/libs/agent/error.rs @@ -52,3 +52,9 @@ impl From for AgentError { AgentError::Internal(e.to_string()) } } + +impl From for AgentError { + fn from(e: crate::tool::ToolError) -> Self { + AgentError::ToolExecutionFailed { tool: String::new(), cause: e.to_string() } + } +} diff --git a/libs/agent/lib.rs b/libs/agent/lib.rs index eeb4172..eec1127 100644 --- a/libs/agent/lib.rs +++ b/libs/agent/lib.rs @@ -13,7 +13,7 @@ pub mod sync; pub mod task; pub mod tokent; pub mod tool; -pub use billing::{BillingRecord, BillingResult, record_ai_usage}; +pub use billing::{BillingRecord, BillingResult, record_ai_usage, initialize_user_billing, initialize_project_billing, check_balance, persist_billing_error}; pub use sync::list_accessible_models; pub use task::TaskService; pub use tokent::{TokenUsage, resolve_usage}; @@ -33,7 +33,7 @@ pub use embed::{ EmbedClient, EmbedMemoryInput, EmbedService, QdrantClient, SearchResult, TagEmbedInput, new_embed_client, }; pub use error::{AgentError, Result}; -pub use react::{ReactConfig, ReactStep, DEFAULT_SYSTEM_PROMPT}; +pub use react::{ReactConfig, ReactStep, DEFAULT_SYSTEM_PROMPT, ROOM_CONTEXT_PROMPT}; pub use tool::{ ToolCall, ToolCallRecord, ToolCallRecorder, ToolCallResult, ToolContext, ToolDefinition, ToolError, ToolExecutor, ToolHandler, ToolParam, ToolRegistry, ToolResult, ToolSchema, diff --git a/libs/agent/perception/vector.rs b/libs/agent/perception/vector.rs index 8671e07..f9fc5a5 100644 --- a/libs/agent/perception/vector.rs +++ b/libs/agent/perception/vector.rs @@ -44,6 +44,10 @@ impl Default for VectorActiveAwareness { } impl VectorActiveAwareness { + pub fn new(max_skills: usize, min_score: f32) -> Self { + Self { max_skills, min_score } + } + /// Search for skills semantically relevant to the user's input. /// /// Uses Qdrant vector search within the given project to find skills whose @@ -107,6 +111,10 @@ impl Default for VectorPassiveAwareness { } impl VectorPassiveAwareness { + pub fn new(max_memories: usize, min_score: f32) -> Self { + Self { max_memories, min_score } + } + /// Search for past conversation messages semantically similar to the current context. /// /// Uses Qdrant to find memories within the same room that share semantic similarity diff --git a/libs/agent/react/mod.rs b/libs/agent/react/mod.rs index 935ce1e..46c48c8 100644 --- a/libs/agent/react/mod.rs +++ b/libs/agent/react/mod.rs @@ -16,7 +16,7 @@ pub const DEFAULT_SYSTEM_PROMPT: &str = r#"You are an AI assistant embedded in a ## Core Rule: Search Local Data First -Always query the platform's local data before guessing or referring to external sources. Local data includes: issues, pull requests, repositories, code reviews, chat messages, documentation, members, and other workspace resources. +Always query the platform's local data before guessing or referring to external sources. Local data includes: issues, pull requests, repositories, code reviews, chat messages, documentation, members, and other project resources. If local data does not contain the answer, state that clearly before considering external information. @@ -38,3 +38,32 @@ If local data does not contain the answer, state that clearly before considering - State ambiguity or uncertainty explicitly. - Prefer facts over speculation. "#; + +/// Room-specific system prompt appended when the AI is @mentioned in a chat room. +/// +/// In room context, the AI must NOT produce long-form output directly. Instead, +/// it communicates through the `send_message` and `retract_message` tools. +/// This keeps room messages concise and gives the AI control over what appears +/// in the room. +pub const ROOM_CONTEXT_PROMPT: &str = r#" +## Room Communication Mode — CRITICAL + +You are NOT in a direct chat. You are @mentioned in a chat room. **Your default response text will NOT be seen by anyone.** The ONLY way to communicate with the room is through the tools listed below. + +### Mandatory Communication Rules + +1. **ALWAYS use `send_message`** to deliver ANY response to the room. No exceptions. If you produce a final text response without calling `send_message`, the room will receive NOTHING. +2. **Call `send_message` FIRST**, before any final text output. The tool call is what creates a visible room message. +3. **Keep each message concise** — short, focused, actionable. No long reports, no multi-paragraph essays, no bullet lists longer than 5 items. If you need to convey a lot of information, summarize the key points and offer to provide details if asked. +4. **Use mentions** to reference entities: `@[user:uuid:username]` for users, `@[repo:uuid:name]` for repositories, `@[skill:slug]` for skills, `@[issue:uuid:title]` for issues, `@[ai:uuid:name]` for other AI models. +5. **Use `retract_message`** to revoke a message you just sent if it contains an error or needs to be withdrawn. You can only retract messages you sent in the current turn. +6. **You may send multiple messages** — for complex responses, break your answer into multiple `send_message` calls (up to 99 per turn). Each message should be short, focused, and stand on its own. For example: first send a summary, then send follow-up details or action items as separate messages. +7. **After calling `send_message`, your final text response can be brief** — just a summary or acknowledgment, since the actual room message has already been delivered via the tool call. + +### Critical Reminder +Your response text output is NOT delivered to the room. The `send_message` tool IS the delivery mechanism. If you forget to call `send_message`, nobody in the room will see your response. + +### Room-Only Tools +- `send_message(room_id?, content)` — Send a brief message to the room. The `room_id` parameter is optional (defaults to the current room). The `content` parameter is required and supports `@[type:id:label]` mention syntax. +- `retract_message(message_id)` — Retract (revoke) a message you sent in the current turn. Requires the message UUID returned by `send_message`. +"#; diff --git a/libs/agent/tokent.rs b/libs/agent/tokent.rs index cd2c7e0..d763a36 100644 --- a/libs/agent/tokent.rs +++ b/libs/agent/tokent.rs @@ -9,8 +9,18 @@ //! return usage metadata (e.g., local models, streaming), tiktoken is used as //! a fallback for accurate counting. +use std::collections::HashMap; +use std::sync::OnceLock; +use std::sync::RwLock; + use crate::error::{AgentError, Result}; +static TOKENIZER_CACHE: OnceLock>> = OnceLock::new(); + +fn get_cached_tokenizers() -> &'static RwLock> { + TOKENIZER_CACHE.get_or_init(|| RwLock::new(HashMap::new())) +} + /// Token usage data. Use `from_remote()` when the API returns usage info, /// or `from_estimate()` when falling back to tiktoken. #[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)] @@ -155,14 +165,28 @@ fn safe_token_budget(context_limit: usize, reserve: usize) -> usize { fn get_tokenizer(model: &str) -> Result { use tiktoken_rs; - // Try model-specific tokenizer first - if let Ok(bpe) = tiktoken_rs::get_bpe_from_model(model) { - return Ok(bpe); + { + let cache = get_cached_tokenizers().read().unwrap(); + if let Some(bpe) = cache.get(model) { + return Ok(bpe.clone()); + } } - // Fallback: use cl100k_base for unknown models - tiktoken_rs::cl100k_base() - .map_err(|e| AgentError::Internal(format!("Failed to init tokenizer: {}", e))) + // Try model-specific tokenizer first + let bpe = if let Ok(bpe) = tiktoken_rs::get_bpe_from_model(model) { + bpe + } else { + // Fallback: use cl100k_base for unknown models + tiktoken_rs::cl100k_base() + .map_err(|e| AgentError::Internal(format!("Failed to init tokenizer: {}", e)))? + }; + + { + let mut cache = get_cached_tokenizers().write().unwrap(); + cache.insert(model.to_string(), bpe.clone()); + } + + Ok(bpe) } /// Estimate tokens for a simple prefix/suffix pattern (e.g., "assistant\n" + text). diff --git a/libs/agent/tool/context.rs b/libs/agent/tool/context.rs index e14cefa..86f7d37 100644 --- a/libs/agent/tool/context.rs +++ b/libs/agent/tool/context.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use db::cache::AppCache; use db::database::AppDatabase; use config::AppConfig; +use queue::MessageProducer; use uuid::Uuid; use super::registry::ToolRegistry; @@ -28,6 +29,15 @@ struct Inner { pub project_id: Uuid, pub registry: ToolRegistry, pub embed_service: Option, + pub message_producer: Option, + /// When in room context, identifies the AI model that is responding. + /// Used by send_message/retract_message to set the correct sender. + pub ai_model_id: Option, + pub ai_model_name: Option, + /// Message IDs sent by the AI in the current ReAct turn. + /// Shared across tool calls so send_message can register IDs + /// and retract_message can validate turn-scoped retraction. + pub sent_in_turn: std::sync::Arc>>, depth: u32, max_depth: u32, tool_call_count: usize, @@ -52,6 +62,10 @@ impl ToolContext { project_id: Uuid::nil(), registry: ToolRegistry::new(), embed_service: None, + message_producer: None, + ai_model_id: None, + ai_model_name: None, + sent_in_turn: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), depth: 0, max_depth: 5, tool_call_count: 0, @@ -85,10 +99,45 @@ impl ToolContext { self } + pub fn with_message_producer(mut self, producer: MessageProducer) -> Self { + Arc::make_mut(&mut self.inner).message_producer = Some(producer); + self + } + + pub fn with_ai_model(mut self, model_id: Uuid, model_name: String) -> Self { + Arc::make_mut(&mut self.inner).ai_model_id = Some(model_id); + Arc::make_mut(&mut self.inner).ai_model_name = Some(model_name); + self + } + + pub fn with_sent_in_turn(mut self, sent: std::sync::Arc>>) -> Self { + Arc::make_mut(&mut self.inner).sent_in_turn = sent; + self + } + + /// Register a message ID as sent in the current turn (called by send_message). + pub fn register_sent_message(&self, id: Uuid) { + if let Ok(mut list) = self.inner.sent_in_turn.lock() { + list.push(id); + } + } + + /// Check if a message ID was sent in the current turn (called by retract_message). + pub fn is_sent_in_turn(&self, id: Uuid) -> bool { + self.inner.sent_in_turn.lock() + .map(|list| list.contains(&id)) + .unwrap_or(false) + } + pub fn embed_service(&self) -> Option<&crate::embed::EmbedService> { self.inner.embed_service.as_ref() } + /// Message queue producer for publishing room events (messages, retractions, etc.). + pub fn message_producer(&self) -> Option<&MessageProducer> { + self.inner.message_producer.as_ref() + } + pub fn recursion_exceeded(&self) -> bool { self.inner.depth >= self.inner.max_depth } @@ -146,6 +195,16 @@ impl ToolContext { self.inner.sender_id } + /// AI model ID when in room context (the AI that is responding). + pub fn ai_model_id(&self) -> Option { + self.inner.ai_model_id + } + + /// AI model display name when in room context. + pub fn ai_model_name(&self) -> Option { + self.inner.ai_model_name.clone() + } + /// Project context for the room. pub fn project_id(&self) -> Uuid { self.inner.project_id diff --git a/libs/agent/tool/rig_adapter.rs b/libs/agent/tool/rig_adapter.rs index 9c47d3b..8891f16 100644 --- a/libs/agent/tool/rig_adapter.rs +++ b/libs/agent/tool/rig_adapter.rs @@ -14,6 +14,7 @@ use super::context::ToolContext; use super::definition::ToolDefinition as AgentToolDefinition; use super::recorder::{ToolCallRecord, ToolCallRecorder}; use super::registry::{ToolHandler, ToolRegistry}; +use queue::MessageProducer; /// Returns true if the tool error message indicates a transient failure that can be retried. pub fn is_retryable_tool_error(msg: &str) -> bool { @@ -170,6 +171,10 @@ impl RigToolSet { room_id: uuid::Uuid, sender_id: Option, project_id: uuid::Uuid, + message_producer: Option, + ai_model_id: Option, + ai_model_name: Option, + sent_in_turn: std::sync::Arc>>, ) -> Self { let mut toolset = ToolSet::default(); let mut definitions = HashMap::new(); @@ -191,6 +196,10 @@ impl RigToolSet { room_id, sender_id, project_id, + message_producer: message_producer.clone(), + ai_model_id, + ai_model_name: ai_model_name.clone(), + sent_in_turn: sent_in_turn.clone(), }; toolset.add_tool(adapter); } @@ -227,6 +236,10 @@ pub struct RigToolAdapter { room_id: uuid::Uuid, sender_id: Option, project_id: uuid::Uuid, + message_producer: Option, + ai_model_id: Option, + ai_model_name: Option, + sent_in_turn: std::sync::Arc>>, } impl RigToolAdapter { @@ -240,8 +253,12 @@ impl RigToolAdapter { room_id: uuid::Uuid, sender_id: Option, project_id: uuid::Uuid, + message_producer: Option, + ai_model_id: Option, + ai_model_name: Option, + sent_in_turn: std::sync::Arc>>, ) -> Self { - Self { handler, definition, db, cache, config, room_id, sender_id, project_id } + Self { handler, definition, db, cache, config, room_id, sender_id, project_id, message_producer, ai_model_id, ai_model_name, sent_in_turn } } } @@ -272,16 +289,27 @@ impl ToolDyn for RigToolAdapter { let room_id = self.room_id; let sender_id = self.sender_id; let project_id = self.project_id; + let message_producer = self.message_producer.clone(); + let ai_model_id = self.ai_model_id; + let ai_model_name = self.ai_model_name.clone(); + let sent_in_turn = self.sent_in_turn.clone(); async move { - let ctx = ToolContext::new( + let mut ctx = ToolContext::new( db, cache, config, room_id, sender_id, ) - .with_project(project_id); + .with_project(project_id) + .with_sent_in_turn(sent_in_turn); + if let Some(mp) = message_producer { + ctx = ctx.with_message_producer(mp); + } + if let Some(mid) = ai_model_id { + ctx = ctx.with_ai_model(mid, ai_model_name.unwrap_or_default()); + } let args_json: serde_json::Value = serde_json::from_str(&args) .map_err(|e| ToolError::JsonError(e))?; diff --git a/libs/api/Cargo.toml b/libs/api/Cargo.toml index 2bfba54..63c4d5a 100644 --- a/libs/api/Cargo.toml +++ b/libs/api/Cargo.toml @@ -26,6 +26,7 @@ email = { workspace = true } tracing = { workspace = true } service = { workspace = true } session = { workspace = true } +agent = { workspace = true } git = { workspace = true } #frontend = { workspace = true } models = { workspace = true } @@ -51,5 +52,12 @@ sea-orm = "2.0.0-rc.37" rust_decimal = "1.40.0" actix-multipart = { workspace = true, features = ["tempfile"] } redis = { workspace = true } +reqwest = { workspace = true, features = ["json", "native-tls", "stream"] } + +[build-dependencies] +brotli = "7" +flate2 = "1" +sha2 = "0.10" + [lints] workspace = true diff --git a/libs/api/agent/mod.rs b/libs/api/agent/mod.rs index e406148..9945797 100644 --- a/libs/api/agent/mod.rs +++ b/libs/api/agent/mod.rs @@ -18,7 +18,7 @@ pub fn init_agent_routes(cfg: &mut web::ServiceConfig) { web::post().to(code_review::trigger_code_review), ) .route( - "/{project}/issues/{issue_number}/triage", + "/{project}/triage", web::get().to(issue_triage::triage_issue), ) .route( diff --git a/libs/api/auth/ws_token.rs b/libs/api/auth/ws_token.rs index 3bc2a53..b1a0524 100644 --- a/libs/api/auth/ws_token.rs +++ b/libs/api/auth/ws_token.rs @@ -1,11 +1,12 @@ use actix_web::{HttpResponse, Result, web}; use serde::Serialize; -use session::SessionUser; +use session::Session; use utoipa::ToSchema; use crate::ApiResponse; use crate::error::ApiError; use service::AppService; +use service::error::AppError; use service::ws_token::WS_TOKEN_TTL_SECONDS; #[derive(Debug, Serialize, ToSchema)] @@ -27,13 +28,16 @@ pub struct WsTokenResponse { )] pub async fn ws_token_generate( service: web::Data, - session_user: SessionUser, + session: Session, ) -> Result { - let SessionUser(user_id) = session_user; + let user_id = session.user().ok_or_else(|| ApiError::from(AppError::Unauthorized))?; + let device_id = session.get::("device_id").unwrap_or_default(); + let client_id = session.get::("client_id").unwrap_or_default(); + let token = service .ws_token - .generate_token(user_id) + .generate_token(user_id, device_id, client_id) .await .map_err(ApiError::from)?; diff --git a/libs/api/build.rs b/libs/api/build.rs index f328e4d..e85d47f 100644 --- a/libs/api/build.rs +++ b/libs/api/build.rs @@ -1 +1,224 @@ -fn main() {} +//! Build script: reads all files from `dist/`, compresses them (brotli + gzip), +//! computes etags via SHA-256, and generates a `frontend` Rust module for `dist.rs`. + +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use flate2::write::GzEncoder; +use flate2::Compression; + +// ── Compression helpers ────────────────────────────────────────────────── + +fn gzip_compress(data: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(6)); + encoder.write_all(data).unwrap(); + encoder.finish().unwrap() +} + +fn brotli_compress(data: &[u8]) -> Option> { + use brotli::CompressorWriter; + let buf = Vec::new(); + let mut writer = CompressorWriter::new(buf, 4096, 6, 16); + if writer.write_all(data).is_ok() && writer.flush().is_ok() { + Some(writer.into_inner()) + } else { + None + } +} + +// ── ETag computation ───────────────────────────────────────────────────── + +fn compute_etag(data: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = hasher.finalize(); + // First 32 hex chars for a compact etag + format!("{:x}", hash)[..32].to_string() +} + +// ── Asset collection ───────────────────────────────────────────────────── + +struct Asset { + path: String, + data: Vec, + etag: String, + brotli: Option>, + gzip: Vec, +} + +fn collect_assets(dist_dir: &Path) -> BTreeMap { + let mut assets = BTreeMap::new(); + + for entry in walkdir(dist_dir) { + let rel = entry.strip_prefix(dist_dir).unwrap(); + let path_str = rel.to_string_lossy().replace('\\', "/"); + if path_str.is_empty() { + continue; + } + + let data = fs::read(&entry).unwrap_or_else(|e| { + panic!("Failed to read dist file {}: {}", path_str, e) + }); + + let etag = compute_etag(&data); + let brotli_data = brotli_compress(&data); + let gzip_data = gzip_compress(&data); + + assets.insert( + path_str.clone(), + Asset { + path: path_str, + data, + etag, + brotli: brotli_data, + gzip: gzip_data, + }, + ); + } + + assets +} + +fn walkdir(dir: &Path) -> Vec { + let mut files = Vec::new(); + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + files.extend(walkdir(&path)); + } else { + files.push(path); + } + } + } + files +} + +// ── Code generation ────────────────────────────────────────────────────── + +fn rust_byte_literal(data: &[u8]) -> String { + if data.len() < 200 { + let bytes: Vec = data.iter().map(|b| b.to_string()).collect(); + format!("[{}]", bytes.join(", ")) + } else { + let lines: Vec = data + .chunks(80) + .map(|chunk| { + chunk.iter().map(|b| b.to_string()).collect::>().join(", ") + }) + .collect(); + format!("[\n{}\n]", lines.join(",\n")) + } +} + +fn path_to_ident(path: &str) -> String { + let s = path.replace('-', "_").replace('.', "_").replace('/', "_").to_uppercase(); + format!("ASSET_{s}") +} + +fn etag_ident(path: &str) -> String { + format!("ETAG_{}", path_to_ident(path)) +} + +fn br_ident(path: &str) -> String { + format!("{}_BR", path_to_ident(path)) +} + +fn gz_ident(path: &str) -> String { + format!("{}_GZ", path_to_ident(path)) +} + +fn generate_frontend_module(assets: &BTreeMap, out_dir: &Path) { + let mut code = String::new(); + + code += "// AUTO-GENERATED by build.rs — DO NOT EDIT.\n"; + code += "// Frontend assets from dist/ embedded as static byte arrays.\n\n"; + + // Generate static byte arrays for each asset + for (path, asset) in assets { + let ident = path_to_ident(path); + let etag_id = etag_ident(path); + let br_id = br_ident(path); + let gz_id = gz_ident(path); + + code += &format!("static {}: &[u8] = &{};\n", ident, rust_byte_literal(&asset.data)); + code += &format!("static {}: &str = \"{}\";\n", etag_id, asset.etag); + if let Some(ref br) = asset.brotli { + code += &format!("static {}: &[u8] = &{};\n", br_id, rust_byte_literal(br)); + } + code += &format!("static {}: &[u8] = &{};\n", gz_id, rust_byte_literal(&asset.gzip)); + code += "\n"; + } + + // Uncompressed lookup + code += "/// Get an uncompressed frontend asset by path, returning (data, etag).\n"; + code += "pub fn get_frontend_asset_with_etag(path: &str) -> Option<(&'static [u8], &'static str)> {\n"; + code += " match path {\n"; + for (path, _asset) in assets { + let ident = path_to_ident(path); + let etag_id = etag_ident(path); + code += &format!(" \"{path}\" => Some((&{ident}, &{etag_id})),\n"); + } + code += " _ => None,\n"; + code += " }\n"; + code += "}\n\n"; + + // Compressed lookup (prefers brotli, falls back to gzip) + code += "/// Get a pre-compressed frontend asset by path.\n"; + code += "/// Returns (data, encoding, etag) — prefers brotli over gzip.\n"; + code += "pub fn get_frontend_asset_compressed(path: &str) -> Option<(&'static [u8], &'static str, &'static str)> {\n"; + code += " match path {\n"; + for (path, asset) in assets { + let etag_id = etag_ident(path); + if asset.brotli.is_some() { + let br_id = br_ident(path); + code += &format!(" \"{path}\" => Some((&{br_id}, \"br\", &{etag_id})),\n"); + } else { + let gz_id = gz_ident(path); + code += &format!(" \"{path}\" => Some((&{gz_id}, \"gzip\", &{etag_id})),\n"); + } + } + code += " _ => None,\n"; + code += " }\n"; + code += "}\n"; + + let out_path = out_dir.join("frontend.rs"); + fs::write(&out_path, code).unwrap_or_else(|e| { + panic!("Failed to write generated frontend.rs: {}", e) + }); +} + +fn main() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let workspace_root = Path::new(&manifest_dir) + .parent() + .unwrap() + .parent() + .unwrap(); + let dist_dir = workspace_root.join("dist"); + + if !dist_dir.exists() { + println!("cargo:warning=dist/ directory not found — frontend assets will not be embedded"); + let out_dir = env::var("OUT_DIR").unwrap(); + let out_path = Path::new(&out_dir).join("frontend.rs"); + fs::write( + &out_path, + "//! No dist/ directory found — frontend assets not embedded.\n\ + pub fn get_frontend_asset_with_etag(_path: &str) -> Option<(&'static [u8], &'static str)> { None }\n\ + pub fn get_frontend_asset_compressed(_path: &str) -> Option<(&'static [u8], &'static str, &'static str)> { None }\n", + ).unwrap(); + return; + } + + println!("cargo:rerun-if-changed=dist/"); + + let assets = collect_assets(&dist_dir); + println!("cargo:warning=Collected {} frontend assets from dist/", assets.len()); + + let out_dir = env::var("OUT_DIR").unwrap(); + generate_frontend_module(&assets, Path::new(&out_dir)); +} \ No newline at end of file diff --git a/libs/api/chat/handlers/conversation.rs b/libs/api/chat/handlers/conversation.rs new file mode 100644 index 0000000..5266150 --- /dev/null +++ b/libs/api/chat/handlers/conversation.rs @@ -0,0 +1,180 @@ +use actix_web::{web, HttpResponse, Result}; +use session::Session; +use service::error::AppError; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::ApiResponse; + +use super::types::{ConversationListQuery, ConversationResponse, CreateConversationParams}; + +fn get_user_id(session: &Session) -> Result { + session.user().ok_or_else(|| ApiError::from(AppError::Unauthorized)) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations", + operation_id = "ai_conversation_create", + request_body = CreateConversationParams, + responses( + (status = 200, description = "Conversation created", body = ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "AI Chat" +)] +pub async fn conversation_create( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let user_id = get_user_id(&session)?; + let model = params.model.clone().unwrap_or_else(|| "gpt-4".to_string()); + + let conversation = service + .create_conversation( + user_id, + params.project_id, + params.title.clone(), + model, + params.model_config.clone(), + params.access_visibility.clone(), + params.can_ask.clone(), + params.model_uid, + params.model_name.clone(), + ) + .await?; + + let resp = ConversationResponse::from(conversation); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations", + operation_id = "ai_conversation_list", + params( + ("project_id" = Option, Query, description = "Filter by project"), + ), + responses( + (status = 200, description = "List of conversations", body = ApiResponse>), + (status = 401, description = "Unauthorized"), + ), + tag = "AI Chat" +)] +pub async fn conversation_list( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let user_id = get_user_id(&session)?; + + let convs = service + .list_conversations(user_id, query.project_id, 50) + .await?; + + let resp: Vec = convs + .into_iter() + .map(ConversationResponse::from) + .collect(); + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}", + operation_id = "ai_conversation_get", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + responses( + (status = 200, description = "Get conversation", body = ApiResponse), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn conversation_get( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + let c = service + .find_conversation_owned(conversation_id, user_id) + .await?; + + let resp = ConversationResponse::from(c); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + patch, + path = "/api/ai/conversations/{conversation_id}", + operation_id = "ai_conversation_update", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + request_body = super::types::UpdateConversationParams, + responses( + (status = 200, description = "Conversation updated"), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn conversation_update( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + service + .update_conversation( + conversation_id, + user_id, + params.title.clone(), + params.model.clone(), + params.model_config.clone(), + params.status.clone(), + params.access_visibility.clone(), + params.can_ask.clone(), + params.model_uid, + params.model_name.clone(), + ) + .await?; + + Ok(crate::api_success()) +} + +#[utoipa::path( + delete, + path = "/api/ai/conversations/{conversation_id}", + operation_id = "ai_conversation_delete", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + responses( + (status = 200, description = "Conversation deleted"), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn conversation_delete( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + service + .delete_conversation(conversation_id, user_id) + .await?; + + Ok(crate::api_success()) +} diff --git a/libs/api/chat/handlers/fork.rs b/libs/api/chat/handlers/fork.rs new file mode 100644 index 0000000..f9118fe --- /dev/null +++ b/libs/api/chat/handlers/fork.rs @@ -0,0 +1,102 @@ +use actix_web::{web, HttpResponse, Result}; +use session::Session; +use service::error::AppError; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::ApiResponse; + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ForkResponse { + pub id: Uuid, + pub conversation_id: Option, + pub source_message_id: Uuid, + pub fork_message_id: Uuid, + #[schema(value_type = chrono::DateTime)] + pub created_at: chrono::DateTime, +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/fork/{target_message_id}", + operation_id = "ai_message_fork", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Source message ID"), + ("target_message_id" = Uuid, Path, description = "Target/fork message ID to create"), + ), + responses( + (status = 200, description = "Fork created", body = ApiResponse), + ), + tag = "AI Chat" +)] +pub async fn message_fork( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid, Uuid)>, +) -> Result { + let user_id = session + .user() + .ok_or_else(|| ApiError::from(AppError::Unauthorized))?; + let (conversation_id, source_message_id, target_message_id) = path.into_inner(); + + let fork_record = service + .fork_message( + conversation_id, + user_id, + source_message_id, + target_message_id, + ) + .await?; + + let resp = ForkResponse { + id: fork_record.id, + conversation_id: fork_record.conversation_id, + source_message_id: fork_record.source_message_id, + fork_message_id: fork_record.fork_message_id, + created_at: fork_record.created_at, + }; + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/forks", + operation_id = "ai_message_forks", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Source message ID"), + ), + responses( + (status = 200, description = "List forks from message", body = ApiResponse>), + ), + tag = "AI Chat" +)] +pub async fn message_forks( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = session + .user() + .ok_or_else(|| ApiError::from(AppError::Unauthorized))?; + let (conversation_id, source_message_id) = path.into_inner(); + + let forks = service + .list_forks(conversation_id, user_id, source_message_id) + .await?; + + let resp: Vec = forks + .into_iter() + .map(|f| ForkResponse { + id: f.id, + conversation_id: f.conversation_id, + source_message_id: f.source_message_id, + fork_message_id: f.fork_message_id, + created_at: f.created_at, + }) + .collect(); + + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/chat/handlers/message.rs b/libs/api/chat/handlers/message.rs new file mode 100644 index 0000000..009283f --- /dev/null +++ b/libs/api/chat/handlers/message.rs @@ -0,0 +1,346 @@ +use crate::error::ApiError; +use crate::ApiResponse; +use actix_web::{web, HttpResponse, Result}; +use session::Session; +use service::error::AppError; +use uuid::Uuid; + +use super::types::{CreateMessageParams, EditMessageParams, MessageListQuery, MessageResponse}; + +fn get_user_id(session: &Session) -> Result { + session.user().ok_or_else(|| ApiError::from(AppError::Unauthorized)) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages", + operation_id = "ai_message_list", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("limit" = Option, Query, description = "Max messages"), + ), + responses( + (status = 200, description = "List messages", body = ApiResponse>), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn message_list( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + let limit = query.limit.unwrap_or(50) as u64; + let msgs = service + .list_messages(conversation_id, user_id, limit) + .await?; + + let resp: Vec = msgs + .into_iter() + .map(MessageResponse::from) + .collect(); + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages", + operation_id = "ai_message_create", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + request_body = CreateMessageParams, + responses( + (status = 200, description = "Message created", body = ApiResponse), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn message_create( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + let msg = service + .create_message( + conversation_id, + user_id, + params.parent_message_id, + params.content.role.clone(), + params.content.content.clone(), + params.model.clone(), + params.is_fork_origin.unwrap_or(false), + params.metadata.clone(), + params.room_id, + ) + .await?; + + let resp = MessageResponse::from(msg); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}", + operation_id = "ai_message_get", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + responses( + (status = 200, description = "Get message", body = ApiResponse), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn message_get( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + let msg = service + .get_message(conversation_id, user_id, message_id) + .await?; + + let resp = MessageResponse::from(msg); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/stop", + operation_id = "ai_message_stop", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + responses( + (status = 200, description = "Message stopped"), + ), + tag = "AI Chat" +)] +pub async fn message_stop( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + service + .stop_message(conversation_id, user_id, message_id) + .await?; + + Ok(crate::api_success()) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/resend", + operation_id = "ai_message_resend", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + responses( + (status = 200, description = "Resend message", body = ApiResponse), + ), + tag = "AI Chat" +)] +pub async fn message_resend( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + let new_msg = service + .resend_message(conversation_id, user_id, message_id) + .await?; + + let resp = MessageResponse::from(new_msg); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/children", + operation_id = "ai_message_children", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Parent message ID"), + ), + responses( + (status = 200, description = "List child messages", body = ApiResponse>), + ), + tag = "AI Chat" +)] +pub async fn message_children( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, parent_message_id) = path.into_inner(); + + let msgs = service + .list_child_messages(conversation_id, user_id, parent_message_id) + .await?; + + let resp: Vec = msgs + .into_iter() + .map(MessageResponse::from) + .collect(); + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/stream", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + responses( + (status = 200, description = "SSE stream"), + ), + tag = "AI Chat" +)] +pub async fn message_stream( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + // Verify user owns the conversation + let conv = service + .find_conversation_owned(conversation_id, user_id) + .await?; + + let model = conv.model; + + let response = actix_web::HttpResponse::Ok() + .content_type("text/event-stream") + .insert_header(("Cache-Control", "no-cache")) + .insert_header(("X-Accel-Buffering", "no")) + .streaming(super::super::stream::create_chat_sse_stream( + service.get_ref().clone(), + conversation_id, + message_id, + model, + user_id, + )); + + Ok(response.into()) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/edit", + operation_id = "ai_message_edit", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID to edit"), + ), + request_body = EditMessageParams, + responses( + (status = 200, description = "Message edited, new version created", body = ApiResponse), + ), + tag = "AI Chat" +)] +pub async fn message_edit( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, + params: web::Json, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + let new_msg = service + .edit_message(conversation_id, user_id, message_id, params.content.clone()) + .await?; + + let resp = MessageResponse::from(new_msg); + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/versions", + operation_id = "ai_message_versions", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + responses( + (status = 200, description = "List message versions", body = ApiResponse>), + ), + tag = "AI Chat" +)] +pub async fn message_versions( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + let versions = service + .list_message_versions(conversation_id, user_id, message_id) + .await?; + + let resp: Vec = versions + .into_iter() + .map(MessageResponse::from) + .collect(); + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/messages/{message_id}/switch-version", + operation_id = "ai_message_switch_version", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("message_id" = Uuid, Path, description = "Message ID"), + ), + request_body = super::types::SwitchVersionParams, + responses( + (status = 200, description = "Version switched", body = ApiResponse), + ), + tag = "AI Chat" +)] +pub async fn message_switch_version( + service: web::Data, + session: Session, + path: web::Path<(Uuid, Uuid)>, + params: web::Json, +) -> Result { + let user_id = get_user_id(&session)?; + let (conversation_id, message_id) = path.into_inner(); + + let msg = service + .switch_message_version(conversation_id, user_id, message_id, params.version_number) + .await?; + + let resp = MessageResponse::from(msg); + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/chat/handlers/mod.rs b/libs/api/chat/handlers/mod.rs new file mode 100644 index 0000000..84854fd --- /dev/null +++ b/libs/api/chat/handlers/mod.rs @@ -0,0 +1,5 @@ +pub mod conversation; +pub mod fork; +pub mod message; +pub mod share; +pub mod types; diff --git a/libs/api/chat/handlers/share.rs b/libs/api/chat/handlers/share.rs new file mode 100644 index 0000000..af92d76 --- /dev/null +++ b/libs/api/chat/handlers/share.rs @@ -0,0 +1,76 @@ +use actix_web::{web, HttpResponse, Result}; +use session::Session; +use service::error::AppError; +use uuid::Uuid; + +use crate::error::ApiError; +use crate::ApiResponse; + +use super::types::{ConversationResponse, ShareResponse}; + +fn get_user_id(session: &Session) -> Result { + session.user().ok_or_else(|| ApiError::from(AppError::Unauthorized)) +} + +#[utoipa::path( + post, + path = "/api/ai/conversations/{conversation_id}/share", + operation_id = "ai_conversation_share", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + responses( + (status = 200, description = "Share token created", body = ApiResponse), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn conversation_share( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let user_id = get_user_id(&session)?; + let conversation_id = path.into_inner(); + + let (share, share_token) = service + .share_conversation(conversation_id, user_id) + .await?; + + let resp = ShareResponse { + id: share.id, + share_token, + view_count: share.view_count, + expires_at: share.expires_at, + }; + + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/share/{share_token}", + operation_id = "ai_shared_conversation_get", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ("share_token" = String, Path, description = "Share token"), + ), + responses( + (status = 200, description = "Get shared conversation", body = ApiResponse), + (status = 404, description = "Not found or expired"), + ), + tag = "AI Chat" +)] +pub async fn shared_conversation_get( + service: web::Data, + path: web::Path<(Uuid, String)>, +) -> Result { + let (conversation_id, share_token) = path.into_inner(); + + let c = service + .get_shared_conversation(conversation_id, share_token) + .await?; + + let resp = ConversationResponse::from(c); + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/chat/handlers/types.rs b/libs/api/chat/handlers/types.rs new file mode 100644 index 0000000..4de083d --- /dev/null +++ b/libs/api/chat/handlers/types.rs @@ -0,0 +1,175 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateConversationParams { + pub project_id: Option, + pub title: Option, + pub model: Option, + pub model_config: Option, + pub access_visibility: Option, + pub can_ask: Option, + /// AI model UUID for model selection + pub model_uid: Option, + /// AI model display name + pub model_name: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ConversationResponse { + pub id: Uuid, + pub user_id: Uuid, + pub project_id: Option, + pub scope: String, + pub title: Option, + pub model: String, + pub model_config: Option, + pub status: String, + pub root_message_id: Option, + pub fork_count: i32, + pub is_shared: bool, + pub message_count: i32, + pub token_usage_total: Option, + pub access_visibility: String, + pub can_ask: String, + pub project_uid: Option, + pub model_uid: Option, + pub model_name: Option, + #[schema(value_type = chrono::DateTime)] + pub created_at: chrono::DateTime, + #[schema(value_type = chrono::DateTime)] + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct UpdateConversationParams { + pub title: Option, + pub model: Option, + pub model_config: Option, + pub status: Option, + pub access_visibility: Option, + pub can_ask: Option, + pub model_uid: Option, + pub model_name: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ConversationListQuery { + pub project_id: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct MessageContent { + pub role: String, + pub content: serde_json::Value, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateMessageParams { + pub parent_message_id: Option, + pub content: MessageContent, + pub model: Option, + pub is_fork_origin: Option, + pub metadata: Option, + pub room_id: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct MessageResponse { + pub id: Uuid, + pub conversation_id: Uuid, + pub parent_message_id: Option, + pub role: String, + pub content: serde_json::Value, + pub model: Option, + pub is_fork_origin: bool, + pub stop_reason: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub latency_ms: Option, + pub metadata: Option, + pub room_id: Option, + pub version_group_id: Option, + pub version_number: i32, + pub is_latest: bool, + #[schema(value_type = chrono::DateTime)] + pub created_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct MessageListQuery { + pub limit: Option, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct EditMessageParams { + pub content: String, +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct SwitchVersionParams { + pub version_number: i32, +} + +#[derive(Debug, Deserialize)] +pub struct ForkParams {} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ShareResponse { + pub id: Uuid, + pub share_token: String, + pub view_count: i32, + #[schema(value_type = Option>)] + pub expires_at: Option>, +} + +impl From for ConversationResponse { + fn from(c: models::ai::ai_conversation::Model) -> Self { + Self { + id: c.id, + user_id: c.user_id, + project_id: c.project_id, + scope: c.scope, + title: c.title, + model: c.model, + model_config: c.model_config, + status: c.status, + root_message_id: c.root_message_id, + fork_count: c.fork_count, + is_shared: c.is_shared, + message_count: c.message_count, + token_usage_total: c.token_usage_total, + access_visibility: c.access_visibility, + can_ask: c.can_ask, + project_uid: c.project_uid, + model_uid: c.model_uid, + model_name: c.model_name, + created_at: c.created_at, + updated_at: c.updated_at, + } + } +} + +impl From for MessageResponse { + fn from(m: models::ai::ai_message::Model) -> Self { + Self { + id: m.id, + conversation_id: m.conversation_id, + parent_message_id: m.parent_message_id, + role: m.role, + content: m.content, + model: m.model, + is_fork_origin: m.is_fork_origin, + stop_reason: m.stop_reason, + input_tokens: m.input_tokens, + output_tokens: m.output_tokens, + latency_ms: m.latency_ms, + metadata: m.metadata, + room_id: m.room_id, + version_group_id: m.version_group_id, + version_number: m.version_number, + is_latest: m.is_latest, + created_at: m.created_at, + } + } +} diff --git a/libs/api/chat/mod.rs b/libs/api/chat/mod.rs new file mode 100644 index 0000000..142b172 --- /dev/null +++ b/libs/api/chat/mod.rs @@ -0,0 +1,85 @@ +use actix_web::web; + +pub mod handlers; +pub mod stream; +pub mod watch; + +pub fn init_chat_routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/ai/conversations") + .route("", web::post().to(handlers::conversation::conversation_create)) + .route("", web::get().to(handlers::conversation::conversation_list)) + .route( + "/{conversation_id}", + web::get().to(handlers::conversation::conversation_get), + ) + .route( + "/{conversation_id}", + web::patch().to(handlers::conversation::conversation_update), + ) + .route( + "/{conversation_id}", + web::delete().to(handlers::conversation::conversation_delete), + ) + .route( + "/{conversation_id}/watch", + web::get().to(watch::conversation_watch), + ) + .route( + "/{conversation_id}/share", + web::post().to(handlers::share::conversation_share), + ) + .route( + "/{conversation_id}/share/{share_token}", + web::get().to(handlers::share::shared_conversation_get), + ) + .route( + "/{conversation_id}/messages", + web::get().to(handlers::message::message_list), + ) + .route( + "/{conversation_id}/messages", + web::post().to(handlers::message::message_create), + ) + .route( + "/{conversation_id}/messages/{message_id}", + web::get().to(handlers::message::message_get), + ) + .route( + "/{conversation_id}/messages/{message_id}/stop", + web::post().to(handlers::message::message_stop), + ) + .route( + "/{conversation_id}/messages/{message_id}/resend", + web::post().to(handlers::message::message_resend), + ) + .route( + "/{conversation_id}/messages/{message_id}/fork/{target_message_id}", + web::post().to(handlers::fork::message_fork), + ) + .route( + "/{conversation_id}/messages/{message_id}/forks", + web::get().to(handlers::fork::message_forks), + ) + .route( + "/{conversation_id}/messages/{message_id}/stream", + web::get().to(handlers::message::message_stream), + ) + .route( + "/{conversation_id}/messages/{message_id}/children", + web::get().to(handlers::message::message_children), + ) + .route( + "/{conversation_id}/messages/{message_id}/edit", + web::post().to(handlers::message::message_edit), + ) + .route( + "/{conversation_id}/messages/{message_id}/versions", + web::get().to(handlers::message::message_versions), + ) + .route( + "/{conversation_id}/messages/{message_id}/switch-version", + web::post().to(handlers::message::message_switch_version), + ), + ); +} diff --git a/libs/api/chat/stream.rs b/libs/api/chat/stream.rs new file mode 100644 index 0000000..4544fd4 --- /dev/null +++ b/libs/api/chat/stream.rs @@ -0,0 +1,463 @@ +use agent::chat::chat_execution; +use agent::chat::{normalize_thinking_content, AiChunkType, AiStreamChunk}; +use agent::client::AiClientConfig; +use agent::client::types::ChatRequestMessage; +use agent::client::StreamChunkType; +use futures::StreamExt; +use models::ai::{ai_message, ai_conversation, AiMessage}; +use queue::{ChatMessageEvent, ChatStreamChunkEvent}; +use sea_orm::{EntityTrait, QueryFilter, ColumnTrait, QueryOrder, ActiveModelTrait, Set, PaginatorTrait}; +use service::AppService; +use std::pin::Pin; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio_stream::wrappers::ReceiverStream; +use uuid::Uuid; + +/// Create an SSE stream that executes AI chat with ReAct tool-calling. +/// +/// Also publishes chat messages and stream chunks via NATS JetStream for +/// multi-viewer support. The requesting client receives SSE events, while +/// other viewers receive chunks via NATS → WebSocket broadcast. +pub fn create_chat_sse_stream( + service: AppService, + conversation_id: Uuid, + user_message_id: Uuid, + model_name: String, + user_id: Uuid, +) -> Pin> + Send>> { + let (tx, rx) = tokio::sync::mpsc::channel::(100); + + let cache = service.cache.clone(); + + tokio::spawn(async move { + // Check for active stream (SSE reconnect recovery) BEFORE starting a new one + // so the frontend can recover from a page refresh. + if let Some((msg_id, started_at)) = cache.get_chat_stream_active(conversation_id).await { + let _ = tx.send(format!( + "data: {{\"event\":\"recovery\",\"data\":{{\"message_id\":\"{}\",\"started_at\":{}}}}}\n\n", + msg_id, + started_at + )).await; + } + + let queue = service.queue_producer.clone(); + let chunk_seq = Arc::new(AtomicU64::new(0)); + + // Build messages from conversation history + let messages = match build_messages_from_history(&service, conversation_id).await { + Ok(msgs) => msgs, + Err(e) => { + let _ = tx.send(format!("data: {{\"event\":\"error\",\"data\":\"{}\"}}\n\n", e)).await; + return; + } + }; + + // Get AI config + let api_key = match service.config.ai_api_key() { + Ok(k) => k, + Err(_) => { + let _ = tx.send("data: {\"event\":\"error\",\"data\":\"AI not configured\"}\n\n".to_string()).await; + return; + } + }; + let base_url = match service.config.ai_basic_url() { + Ok(u) => u, + Err(_) => { + let _ = tx.send("data: {\"event\":\"error\",\"data\":\"AI not configured\"}\n\n".to_string()).await; + return; + } + }; + + let config = AiClientConfig::new(api_key).with_base_url(&base_url); + + // Get tools from ChatService if available + let (tools, tool_registry, embed_service) = match &service.chat_service { + Some(cs) => ( + cs.tools(), + cs.tool_registry().cloned(), + service.embed_service.as_ref().map(|es| (**es).clone()), + ), + None => (Vec::new(), None, None), + }; + + // Get project_id from conversation + let project_id = match service.find_conversation(conversation_id).await { + Ok(c) => c.project_id.unwrap_or(Uuid::nil()), + Err(_) => { + let _ = tx.send("data: {\"event\":\"error\",\"data\":\"conversation not found\"}\n\n".to_string()).await; + return; + } + }; + + // Pre-flight balance check: verify project + user can afford at least a minimal AI call + let balance_ok = agent::billing::check_balance( + &service.db, project_id, user_id, Uuid::nil(), 500, 250, + ).await; + + match balance_ok { + Ok(true) => {}, + Ok(false) => { + tracing::warn!(project_id = %project_id, user_id = %user_id, "Insufficient balance for chat AI call"); + + let _ = agent::billing::persist_billing_error( + &service.db, "user", user_id, "insufficient_balance", + &format!("Insufficient balance. Your account does not have enough funds for this AI request."), + Some(serde_json::json!({ + "user_id": user_id.to_string(), + "project_id": project_id.to_string(), + })), + ).await; + + let error_msg = "Insufficient balance. Your account does not have enough funds to process this AI request. Please add credits to continue."; + let _ = tx.send(format!("data: {{\"event\":\"billing_error\",\"data\":\"{}\"}}\n\n", error_msg)).await; + let _ = tx.send("data: {\"event\":\"done\",\"data\":\"billing_error\"}\n\n".to_string()).await; + return; + }, + Err(e) => { + tracing::warn!(error = %e, "Balance check failed, proceeding without pre-flight check"); + } + } + + let max_tool_depth = 99; + + // Determine conversation project_id for chat message event + let conv_project_id = match service.find_conversation(conversation_id).await { + Ok(c) => c.project_id, + Err(_) => None, + }; + + // Broadcast chat message start event via NATS + let chat_msg = ChatMessageEvent { + message_id: user_message_id, + conversation_id, + project_id: conv_project_id, + sender_id: Uuid::nil(), + role: "assistant".to_string(), + content: String::new(), + model: Some(model_name.clone()), + input_tokens: None, + output_tokens: None, + timestamp: chrono::Utc::now(), + }; + let _ = queue.publish_chat_message(&chat_msg).await; + + // Mark stream as active in Redis so page refresh can recover + let _ = cache.set_chat_stream_active(conversation_id, user_message_id).await; + + let on_chunk_tx = tx.clone(); + let on_chunk_queue = queue.clone(); + let on_chunk_seq = chunk_seq.clone(); + let on_chunk_conv_id = conversation_id; + let on_chunk_msg_id = user_message_id; + let on_chunk_model = model_name.clone(); + + let on_chunk: agent::chat::StreamCallback = Box::new(move |chunk: AiStreamChunk| { + let tx = on_chunk_tx.clone(); + let queue = on_chunk_queue.clone(); + let seq = on_chunk_seq.fetch_add(1, Ordering::Relaxed); + let conv_id = on_chunk_conv_id; + let msg_id = on_chunk_msg_id; + let model = on_chunk_model.clone(); + Box::pin(async move { + let event = match chunk.chunk_type { + AiChunkType::Thinking => "thinking", + AiChunkType::Answer => "token", + AiChunkType::ToolCall => "tool_call", + AiChunkType::ToolResult => "tool_result", + }; + let content = match chunk.chunk_type { + AiChunkType::Thinking => normalize_thinking_content(&chunk.content), + _ => chunk.content.clone(), + }; + let sse = format!( + "data: {{\"event\":\"{}\",\"data\":{}}}\n\n", + event, + serde_json::to_string(&content).unwrap_or_default() + ); + let _ = tx.send(sse).await; + + // Also broadcast via NATS for other viewers + let natts_chunk = ChatStreamChunkEvent { + conversation_id: conv_id, + message_id: msg_id, + seq, + content, + done: false, + error: None, + chunk_type: Some(event.to_string()), + model_name: Some(model), + }; + queue.publish_chat_chunk(&natts_chunk).await; + }) as Pin + Send>> + }); + + let result = chat_execution::execute_chat_stream( + messages, + tools, + &model_name, + &config, + 0.7, // temperature + 4096, // max_tokens + max_tool_depth, + tool_registry.as_ref(), + service.db.clone(), + service.cache.clone(), + service.config.clone(), + project_id, + Uuid::nil(), // sender_uid — unknown in Chat API context + embed_service, + on_chunk, + Some(conversation_id), + ).await; + + // Clear stream active state (streaming finished) + let _ = cache.clear_chat_stream_active(conversation_id).await; + + match result { + Ok(stream_result) => { + // Build ordered content blocks from stream chunks, merging + // consecutive blocks of the same role (thinking/assistant). + let raw_blocks: Vec<(String, String)> = stream_result.chunks.iter() + .filter(|c| matches!(c.chunk_type, StreamChunkType::Thinking | StreamChunkType::Answer)) + .map(|chunk| { + let role = match chunk.chunk_type { + StreamChunkType::Thinking => "thinking", + _ => "assistant", + }; + (role.to_string(), chunk.content.clone()) + }) + .collect(); + + let merged_blocks = merge_consecutive_blocks(raw_blocks); + // Apply thinking normalization to the fully merged thinking + // blocks — per-token normalization is meaningless since each + // chunk is a single token. + let normalized_blocks: Vec<(String, String)> = merged_blocks.into_iter().map(|(role, content)| { + if role == "thinking" { + (role, normalize_thinking_content(&content)) + } else { + (role, content) + } + }).collect(); + let content_blocks: Vec = normalized_blocks.iter() + .map(|(role, content)| serde_json::json!({ "role": role, "content": content })) + .collect(); + let content_value = if content_blocks.is_empty() { + serde_json::json!([{ "role": "assistant", "content": stream_result.content }]) + } else { + serde_json::json!(content_blocks) + }; + + // Persist assistant message + let assistant_msg_id = Uuid::now_v7(); + let assistant_msg = ai_message::ActiveModel { + id: Set(assistant_msg_id), + conversation_id: Set(conversation_id), + parent_message_id: Set(Some(user_message_id)), + role: Set("assistant".to_string()), + content: Set(content_value), + model: Set(Some(model_name.clone())), + is_fork_origin: Set(false), + stop_reason: Set(Some("stop".to_string())), + input_tokens: Set(Some(stream_result.input_tokens as i32)), + output_tokens: Set(Some(stream_result.output_tokens as i32)), + latency_ms: Set(None), + metadata: Set(None), + room_id: Set(None), + version_group_id: Set(Some(assistant_msg_id)), + version_number: Set(1), + is_latest: Set(true), + created_at: Set(chrono::Utc::now()), + }; + + let saved = assistant_msg.insert(service.db.writer()).await; + + if let Ok(msg) = &saved { + update_conversation_after_response(&service, conversation_id, msg).await; + + // After AI response, check/update conversation title and emit via SSE + if let Ok(Some(conv)) = ai_conversation::Entity::find_by_id(conversation_id) + .one(service.db.reader()).await + { + let existing_title = conv.title.clone(); + let needs_title = existing_title.as_deref().map(|t| t.is_empty() || t == "New Chat").unwrap_or(true); + + if needs_title { + // Generate title from first user message + let first_user_msg = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::Role.eq("user")) + .order_by_asc(ai_message::Column::CreatedAt) + .one(service.db.reader()).await.ok().flatten(); + + if let Some(user_msg) = first_user_msg { + let content = match &user_msg.content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => { + arr.first() + .and_then(|f| f.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string() + } + other => other.to_string(), + }; + + // Simple title extraction: first meaningful words + let title = content + .split_whitespace() + .filter(|w| w.len() > 2) + .take(5) + .collect::>() + .join(" "); + + if !title.is_empty() { + let truncated: String = title.chars().take(40).collect(); + + // Save title to DB + let mut active: ai_conversation::ActiveModel = conv.into(); + active.title = Set(Some(truncated.clone())); + active.updated_at = Set(chrono::Utc::now()); + let _ = active.update(service.db.writer()).await; + + // Emit title via SSE + let title_payload = serde_json::json!({"title": truncated}).to_string(); + let _ = tx.send(format!("data: {{\"event\":\"title\",\"data\":{}}}\n\n", title_payload)).await; + } + } + } else if let Some(title) = &existing_title { + // Title already set (e.g. by AI tool) — emit it + let title_payload = serde_json::json!({"title": title}).to_string(); + let _ = tx.send(format!("data: {{\"event\":\"title\",\"data\":{}}}\n\n", title_payload)).await; + } + } + } + + // Broadcast final chat message with token usage + let final_msg = ChatMessageEvent { + message_id: user_message_id, + conversation_id, + project_id: conv_project_id, + sender_id: Uuid::nil(), + role: "assistant".to_string(), + content: stream_result.content.clone(), + model: Some(model_name.clone()), + input_tokens: Some(stream_result.input_tokens as i32), + output_tokens: Some(stream_result.output_tokens as i32), + timestamp: chrono::Utc::now(), + }; + let _ = queue.publish_chat_message(&final_msg).await; + + // Send final SSE done event + let _ = tx.send("data: {\"event\":\"done\",\"data\":\"ok\"}\n\n".to_string()).await; + } + Err(e) => { + let _ = tx.send(format!("data: {{\"event\":\"error\",\"data\":\"{}\"}}\n\n", e)).await; + } + } + }); + + Box::pin(ReceiverStream::new(rx).map(|msg| Ok(actix_web::web::Bytes::from(msg)))) +} + +/// Update conversation metadata after an AI assistant message is saved. +async fn update_conversation_after_response( + service: &AppService, + conversation_id: Uuid, + assistant_msg: &ai_message::Model, +) { + use models::ai::ai_conversation; + use sea_orm::EntityTrait; + + if let Ok(Some(conv)) = ai_conversation::Entity::find_by_id(conversation_id) + .one(service.db.reader()).await + { + let input_tokens = assistant_msg.input_tokens.unwrap_or(0) as i64; + let output_tokens = assistant_msg.output_tokens.unwrap_or(0) as i64; + let total_tokens = input_tokens + output_tokens; + + let mut active: ai_conversation::ActiveModel = conv.into(); + if let Ok(count) = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .count(service.db.reader()).await + { + active.message_count = Set(count as i32); + } + active.token_usage_total = Set(Some(total_tokens as i32)); + active.updated_at = Set(chrono::Utc::now()); + let _ = active.update(service.db.writer()).await; + } +} + +/// Build ChatRequestMessage list from ai_message conversation history. +async fn build_messages_from_history( + service: &AppService, + conversation_id: Uuid, +) -> Result, String> { + let msgs = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .order_by_asc(ai_message::Column::CreatedAt) + .all(service.db.reader()) + .await + .map_err(|e| format!("db error: {}", e))?; + + let mut chat_messages = Vec::new(); + + for msg in &msgs { + let role = msg.role.as_str(); + let content = match &msg.content { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Array(arr) => { + // Content is ordered blocks: [{role:"thinking",content:"..."}, {role:"assistant","content":"..."}, ...] + // For assistant messages: concatenate all "assistant" blocks + // For user/system messages: take the first block's content + if role == "assistant" { + arr.iter() + .filter(|item| item.get("role").and_then(|r| r.as_str()) != Some("thinking")) + .filter_map(|item| item.get("content").and_then(|c| c.as_str())) + .collect::>() + .join("\n") + } else if let Some(first) = arr.first() { + first.get("content") + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string() + } else { + String::new() + } + } + other => other.to_string(), + }; + + match role { + "user" => chat_messages.push(ChatRequestMessage::user(content)), + "assistant" => chat_messages.push(ChatRequestMessage::assistant(Some(content), None)), + "system" => chat_messages.push(ChatRequestMessage::system(content)), + _ => chat_messages.push(ChatRequestMessage::user(content)), + } + } + + Ok(chat_messages) +} + +/// Merge consecutive content blocks of the same role into single blocks. +/// This transforms many small per-chunk blocks into clean interleaved segments: +/// [thinking, thinking, assistant, assistant] → [thinking, assistant] +/// Per-token chunks are concatenated directly — the model sends \n inside +/// the token content where needed, not between tokens. +fn merge_consecutive_blocks(blocks: Vec<(String, String)>) -> Vec<(String, String)> { + let mut merged: Vec<(String, String)> = Vec::new(); + for (role, content) in blocks { + if content.is_empty() { continue; } + if let Some(last) = merged.last_mut() { + if last.0 == role { + last.1.push_str(&content); + continue; + } + } + merged.push((role, content)); + } + merged +} diff --git a/libs/api/chat/watch.rs b/libs/api/chat/watch.rs new file mode 100644 index 0000000..adf4911 --- /dev/null +++ b/libs/api/chat/watch.rs @@ -0,0 +1,158 @@ +//! SSE endpoint for watching a chat conversation in real-time via NATS. +//! +//! Unlike the primary SSE stream (which triggers AI execution), this endpoint +//! passively subscribes to NATS Core subjects and forwards chat messages and +//! stream chunks to connected clients. This enables multiple viewers to watch +//! the same AI conversation in real-time. + +use actix_web::{web, HttpResponse, Result}; +use futures::StreamExt; +use service::AppService; +use std::pin::Pin; +use uuid::Uuid; + +use crate::error::ApiError; + +/// SSE endpoint for watching a chat conversation. +/// +/// `GET /api/ai/conversations/{conversation_id}/watch` +/// +/// Subscribes to NATS Core subjects (`chat.chunk.{id}` and `chat.message.{id}`) +/// and forwards received events as SSE to the connected client. +/// +/// SSE events: +/// - `chunk` — a stream chunk (thinking, token, tool_call, tool_result, done, error) +/// - `message` — a complete chat message +/// - `error` — an error event +pub fn create_watch_sse_stream( + service: AppService, + conversation_id: Uuid, +) -> Pin> + Send>> { + let (tx, rx) = tokio::sync::mpsc::channel::(200); + + tokio::spawn(async move { + let nats = match &service.queue_producer.nats { + Some(n) => n.clone(), + None => { + let _ = tx.send(format!( + "data: {{\"event\":\"error\",\"data\":{}}}\n\n", + serde_json::to_string("NATS not available").unwrap_or_default() + )).await; + return; + } + }; + + // Subscribe to chat chunks + let chunk_subject = format!("chat.chunk.{}", conversation_id); + let mut chunk_sub = match nats.subscribe(&chunk_subject).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(format!( + "data: {{\"event\":\"error\",\"data\":{}}}\n\n", + serde_json::to_string(&e.to_string()).unwrap_or_default() + )).await; + return; + } + }; + + // Subscribe to chat messages + let msg_subject = format!("chat.message.{}", conversation_id); + let mut msg_sub = match nats.subscribe(&msg_subject).await { + Ok(s) => s, + Err(e) => { + let _ = tx.send(format!( + "data: {{\"event\":\"error\",\"data\":{}}}\n\n", + serde_json::to_string(&e.to_string()).unwrap_or_default() + )).await; + return; + } + }; + + let _ = tx.send(":ok\n\n".to_string()).await; + + loop { + tokio::select! { + chunk_msg = chunk_sub.next() => { + match chunk_msg { + Some(msg) => { + let payload = String::from_utf8_lossy(&msg.payload); + // Parse to get chunk_type for the event field + let event_type = if let Ok(parsed) = serde_json::from_str::(&payload) { + parsed.get("chunk_type") + .and_then(|v| v.as_str()) + .unwrap_or("chunk") + .to_string() + } else { + "chunk".to_string() + }; + let sse = format!( + "data: {{\"event\":\"{}\",\"data\":{}}}\n\n", + event_type, payload + ); + if tx.send(sse).await.is_err() { + break; + } + } + None => break, + } + } + msg = msg_sub.next() => { + match msg { + Some(msg) => { + let payload = String::from_utf8_lossy(&msg.payload); + let sse = format!( + "data: {{\"event\":\"message\",\"data\":{}}}\n\n", + payload + ); + if tx.send(sse).await.is_err() { + break; + } + } + None => break, + } + } + } + } + }); + + Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx).map(|s| { + Ok(actix_web::web::Bytes::from(s)) + })) +} + +#[utoipa::path( + get, + path = "/api/ai/conversations/{conversation_id}/watch", + params( + ("conversation_id" = Uuid, Path, description = "Conversation ID"), + ), + responses( + (status = 200, description = "SSE stream of conversation events"), + (status = 404, description = "Not found"), + ), + tag = "AI Chat" +)] +pub async fn conversation_watch( + service: web::Data, + session: session::Session, + path: web::Path, +) -> Result { + let user_id = session.user().ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?; + let conversation_id = path.into_inner(); + + // Verify access (view-only is sufficient) + let _conv = service + .find_conversation_owned(conversation_id, user_id) + .await?; + + let response = HttpResponse::Ok() + .content_type("text/event-stream") + .insert_header(("Cache-Control", "no-cache")) + .insert_header(("X-Accel-Buffering", "no")) + .streaming(create_watch_sse_stream( + service.get_ref().clone(), + conversation_id, + )); + + Ok(response.into()) +} diff --git a/libs/api/dist.rs b/libs/api/dist.rs index 22a9f7c..55c4dca 100644 --- a/libs/api/dist.rs +++ b/libs/api/dist.rs @@ -116,20 +116,30 @@ pub async fn serve_frontend(req: HttpRequest, path: web::Path) -> HttpRe }; let cc = cache_control_header(path_str); - // Try brotli first (best compression), then gzip, then uncompressed. - // Only serve compressed variant if client explicitly accepts it AND we have one. - let (data, encoding, etag, content_path) = - match frontend::get_frontend_asset_compressed(path_str) { - Some(r) => (r.0, r.1, r.2, path_str), - None => { - // Path not found — try index.html as SPA fallback. - // Also use "index.html" for Content-Type detection (text/html). - match frontend::get_frontend_asset_with_etag("index.html") { - Some((data, etag)) => (data, "", etag, "index.html"), - None => return HttpResponse::NotFound().finish(), - } - } - }; + // Try brotli/gzip compressed variant first (best compression), + // then fall back to uncompressed if client doesn't accept the encoding. + let compressed = crate::frontend::get_frontend_asset_compressed(path_str); + let uncompressed = crate::frontend::get_frontend_asset_with_etag(path_str); + + let (data, encoding, etag, content_path) = if let Some((c_data, c_enc, c_etag)) = compressed { + if accepts_encoding(&req, c_enc) { + (c_data, c_enc, c_etag, path_str) + } else if let Some((u_data, u_etag)) = uncompressed { + // Client doesn't accept the pre-compressed encoding — serve uncompressed. + (u_data, "", u_etag, path_str) + } else { + // No uncompressed fallback — still serve compressed (client must handle it). + (c_data, c_enc, c_etag, path_str) + } + } else if let Some((data, etag)) = uncompressed { + (data, "", etag, path_str) + } else { + // Path not found — try index.html as SPA fallback. + match crate::frontend::get_frontend_asset_with_etag("index.html") { + Some((data, etag)) => (data, "", etag, "index.html"), + None => return HttpResponse::NotFound().finish(), + } + }; if !encoding.is_empty() && accepts_encoding(&req, &encoding) { build_asset_response(&req, data, etag, content_path, cc, &encoding) diff --git a/libs/api/frontend.rs b/libs/api/frontend.rs new file mode 100644 index 0000000..eb67ce5 --- /dev/null +++ b/libs/api/frontend.rs @@ -0,0 +1,4 @@ +//! Frontend assets module — auto-generated by build.rs. +//! The actual content is generated at $OUT_DIR/frontend.rs by the build script. + +include!(concat!(env!("OUT_DIR"), "/frontend.rs")); \ No newline at end of file diff --git a/libs/api/git/init.rs b/libs/api/git/init.rs index bde4446..b1b4815 100644 --- a/libs/api/git/init.rs +++ b/libs/api/git/init.rs @@ -4,6 +4,21 @@ use service::AppService; use service::git::init::GitInitRequest; use session::Session; +fn sanitize_repo_path(path: &str) -> Result { + if path.contains("..") || path.contains('~') { + return Err(ApiError(service::error::AppError::BadRequest( + "Invalid repository path".to_string() + ))); + } + if path.starts_with('/') || path.starts_with('\\') || + (path.len() >= 3 && path.as_bytes()[1] == b':' && path.as_bytes()[2] == b'\\') { + return Err(ApiError(service::error::AppError::BadRequest( + "Absolute paths are not allowed".to_string() + ))); + } + Ok(path.to_string()) +} + #[utoipa::path( post, path = "/api/git/init", @@ -42,7 +57,8 @@ pub async fn git_open( session: Session, path: web::Path, ) -> Result { - let resp = service.git_open(path.into_inner(), &session).await?; + let path = sanitize_repo_path(&path.into_inner())?; + let resp = service.git_open(path, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } @@ -64,7 +80,8 @@ pub async fn git_open_bare( session: Session, path: web::Path, ) -> Result { - let resp = service.git_open_bare(path.into_inner(), &session).await?; + let path = sanitize_repo_path(&path.into_inner())?; + let resp = service.git_open_bare(path, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } @@ -86,7 +103,8 @@ pub async fn git_is_repo( session: Session, path: web::Path, ) -> Result { - let resp = service.git_is_repo(path.into_inner(), &session).await?; + let path = sanitize_repo_path(&path.into_inner())?; + let resp = service.git_is_repo(path, &session).await?; Ok(ApiResponse::ok(resp).to_response()) } diff --git a/libs/api/git/star.rs b/libs/api/git/star.rs index 5a0466f..09d0216 100644 --- a/libs/api/git/star.rs +++ b/libs/api/git/star.rs @@ -7,14 +7,15 @@ use session::Session; #[derive(serde::Deserialize, utoipa::IntoParams)] pub struct StarPagerQuery { pub page: Option, - pub par_page: Option, + #[serde(alias = "par_page")] + pub per_page: Option, } impl From for service::Pager { fn from(q: StarPagerQuery) -> Self { service::Pager { page: q.page.unwrap_or(1), - par_page: q.par_page.unwrap_or(20), + per_page: q.per_page.unwrap_or(20), } } } diff --git a/libs/api/git/watch.rs b/libs/api/git/watch.rs index 02641ca..7f91300 100644 --- a/libs/api/git/watch.rs +++ b/libs/api/git/watch.rs @@ -7,14 +7,15 @@ use session::Session; #[derive(serde::Deserialize, utoipa::IntoParams)] pub struct WatchPagerQuery { pub page: Option, - pub par_page: Option, + #[serde(alias = "par_page")] + pub per_page: Option, } impl From for service::Pager { fn from(q: WatchPagerQuery) -> Self { service::Pager { page: q.page.unwrap_or(1), - par_page: q.par_page.unwrap_or(20), + per_page: q.per_page.unwrap_or(20), } } } diff --git a/libs/api/lib.rs b/libs/api/lib.rs index 3e1c618..a647dbf 100644 --- a/libs/api/lib.rs +++ b/libs/api/lib.rs @@ -1,5 +1,7 @@ pub mod agent; pub mod auth; +pub mod chat; +pub mod dist; pub mod error; pub mod git; pub mod issue; @@ -13,6 +15,8 @@ pub mod search; pub mod sidemap; pub mod skill; pub mod user; -pub mod workspace; + +// Auto-generated frontend module (from build.rs) serving embedded dist/ assets +mod frontend; pub use error::{api_success, ApiError, ApiResponse}; diff --git a/libs/api/openapi.rs b/libs/api/openapi.rs index 2f6fb61..9aa9a17 100644 --- a/libs/api/openapi.rs +++ b/libs/api/openapi.rs @@ -280,8 +280,12 @@ use utoipa::OpenApi; crate::project::repo::project_repos, crate::project::repo::project_repo_create, crate::project::members::project_members, + crate::project::members::project_members_grouped, crate::project::members::project_update_member_role, crate::project::members::project_remove_member, + crate::project::members::project_role_priorities, + crate::project::members::project_upsert_role_priority, + crate::project::members::project_delete_role_priority, crate::project::labels::project_labels, crate::project::labels::project_create_label, crate::project::labels::project_get_label, @@ -318,8 +322,10 @@ use utoipa::OpenApi; crate::project::audit::project_log_audit, crate::project::activity::project_activities, crate::project::activity::project_log_activity, + crate::project::stats::project_stats, crate::project::billing::project_billing, crate::project::billing::project_billing_history, + crate::project::billing::project_billing_errors, crate::project::invitation::project_my_invitations, crate::project::invitation::project_invitations, crate::project::invitation::project_invite_user, @@ -373,13 +379,16 @@ use utoipa::OpenApi; crate::room::room::room_create, crate::room::room::room_update, crate::room::room::room_delete, + crate::room::room::project_presence, crate::room::category::category_list, crate::room::category::category_create, crate::room::category::category_update, crate::room::category::category_delete, + crate::room::message::message_list, crate::room::message::message_create, crate::room::message::message_update, crate::room::message::message_revoke, + crate::room::message::message_get, crate::room::thread::thread_list, crate::room::thread::thread_create, crate::room::thread::thread_messages, @@ -409,8 +418,12 @@ use utoipa::OpenApi; crate::user::profile::get_my_profile, crate::user::profile::update_my_profile, crate::user::profile::get_profile_by_username, + crate::user::avatar::upload_avatar, crate::user::preferences::get_preferences, crate::user::preferences::update_preferences, + crate::user::billing::user_billing, + crate::user::billing::user_billing_errors, + crate::user::billing::user_billing_history, crate::user::ssh_key::add_ssh_key, crate::user::ssh_key::list_ssh_keys, crate::user::ssh_key::get_ssh_key, @@ -434,6 +447,7 @@ use utoipa::OpenApi; crate::user::subscribe::get_subscription_count, crate::user::subscribe::get_subscriber_count, crate::user::subscribe::get_following_list, + crate::user::summary::get_user_summary, crate::user::user_activity::get_user_activity, crate::user::stars::get_user_stars, crate::user::user_info::get_user_info, @@ -444,26 +458,22 @@ use utoipa::OpenApi; crate::skill::skill_update, crate::skill::skill_delete, crate::skill::skill_scan, - // Workspace - crate::workspace::init::workspace_create, - crate::workspace::info::workspace_list, - crate::workspace::info::workspace_info, - crate::workspace::projects::workspace_projects, - crate::workspace::stats::workspace_stats, - crate::workspace::billing::workspace_billing_current, - crate::workspace::billing::workspace_billing_history, - crate::workspace::billing::workspace_billing_add_credit, - crate::workspace::members::workspace_members, - crate::workspace::members::workspace_update_member_role, - crate::workspace::members::workspace_remove_member, - crate::workspace::members::workspace_invite_member, - crate::workspace::members::workspace_pending_invitations, - crate::workspace::members::workspace_cancel_invitation, - crate::workspace::members::workspace_accept_invitation, - crate::workspace::members::workspace_my_invitations, - crate::workspace::members::workspace_accept_invitation_by_slug, - crate::workspace::settings::workspace_update, - crate::workspace::settings::workspace_delete, + // AI Chat + crate::chat::handlers::conversation::conversation_create, + crate::chat::handlers::conversation::conversation_list, + crate::chat::handlers::conversation::conversation_get, + crate::chat::handlers::conversation::conversation_update, + crate::chat::handlers::conversation::conversation_delete, + crate::chat::handlers::message::message_list, + crate::chat::handlers::message::message_create, + crate::chat::handlers::message::message_get, + crate::chat::handlers::message::message_stop, + crate::chat::handlers::message::message_resend, + crate::chat::handlers::message::message_children, + crate::chat::handlers::message::message_stream, + crate::chat::handlers::fork::message_fork, + crate::chat::handlers::share::conversation_share, + crate::chat::handlers::share::shared_conversation_get, ), components( schemas( @@ -538,7 +548,11 @@ use utoipa::OpenApi; service::project::repo::ProjectRepoCreateParams, service::project::repo::ProjectRepoCreateResponse, service::project::members::MemberListResponse, + service::project::members::GroupedMemberListResponse, service::project::members::UpdateMemberRoleRequest, + service::project::members::RolePriorityListResponse, + service::project::members::RolePriorityInfo, + service::project::members::UpsertRolePriorityRequest, service::project::labels::LabelListResponse, service::project::labels::LabelResponse, service::project::labels::CreateLabelParams, @@ -550,6 +564,9 @@ use utoipa::OpenApi; service::project::activity::ActivityLogResponse, service::project::activity::ActivityLogParams, service::project::activity::ActivityLogListResponse, + service::project::stats::ProjectStatsResponse, + service::project::stats::ActivityBreakdownItem, + service::project::stats::ProjectStatsActivityItem, // Skill service::skill::info::SkillResponse, service::skill::manage::CreateSkillRequest, @@ -572,6 +589,8 @@ use utoipa::OpenApi; service::project::billing::ProjectBillingCurrentResponse, service::project::billing::ProjectBillingHistoryResponse, service::project::billing::ProjectBillingHistoryQuery, + service::project::billing::BillingErrorItem, + service::project::billing::BillingErrorsResponse, service::project::invitation::InvitationListResponse, service::project::join_settings::JoinSettingsResponse, service::project::join_settings::UpdateJoinSettingsRequest, @@ -614,6 +633,7 @@ use utoipa::OpenApi; // User service::user::profile::ProfileResponse, service::user::profile::UpdateProfileParams, + crate::user::avatar::AvatarUploadResponse, service::user::preferences::PreferencesResponse, service::user::preferences::PreferencesParams, service::user::ssh_key::SshKeyResponse, @@ -633,33 +653,19 @@ use utoipa::OpenApi; service::user::repository::UserReposQuery, service::user::subscribe::SubscriptionInfo, service::user::subscribe::UserCard, + service::user::summary::UserSummaryResponse, service::user::user_activity::UserActivityItem, service::user::user_activity::UserActivityResponse, service::user::stars::RepoStarItem, service::user::stars::ProjectFollowItem, service::user::stars::UserStarsResponse, service::user::user_info::UserInfoExternal, - // Workspace - service::workspace::init::WorkspaceInitParams, - service::workspace::info::WorkspaceInfoResponse, - service::workspace::info::WorkspaceListItem, - service::workspace::info::WorkspaceListResponse, - service::workspace::info::WorkspaceProjectsQuery, - service::workspace::info::WorkspaceProjectsResponse, - service::workspace::info::WorkspaceProjectItem, - service::workspace::info::WorkspaceStatsResponse, - service::workspace::billing::WorkspaceBillingCurrentResponse, - service::workspace::billing::WorkspaceBillingHistoryResponse, - service::workspace::billing::WorkspaceBillingHistoryQuery, - service::workspace::billing::WorkspaceBillingAddCreditParams, - service::workspace::members::WorkspaceMemberInfo, - service::workspace::members::WorkspaceMembersResponse, - service::workspace::members::WorkspaceInviteParams, - service::workspace::members::WorkspaceInviteAcceptParams, - service::workspace::members::PendingInvitationInfo, - service::workspace::members::MyWorkspaceInvitation, - service::workspace::members::WorkspaceAcceptBySlugParams, - service::workspace::settings::WorkspaceUpdateParams, + service::user::billing::UserBillingResponse, + service::user::billing::UserBillingErrorsResponse, + service::user::billing::UserBillingErrorItem, + service::user::billing::UserBillingHistoryQuery, + service::user::billing::UserBillingHistoryItem, + service::user::billing::UserBillingHistoryResponse, // Room room::RoomResponse, room::RoomCreateRequest, @@ -686,6 +692,9 @@ use utoipa::OpenApi; room::NotificationResponse, room::NotificationListResponse, room::NotificationType, + // Presence + room::presence::PresenceChanged, + room::presence::PresenceStatus, // Auth service types service::auth::login::LoginParams, service::auth::register::RegisterParams, @@ -726,6 +735,15 @@ use utoipa::OpenApi; service::search::UserSearchItem, service::search::GlobalMessageSearchResponse, service::search::GlobalMessageSearchItem, + // AI Chat + crate::chat::handlers::types::CreateConversationParams, + crate::chat::handlers::types::UpdateConversationParams, + crate::chat::handlers::types::ConversationResponse, + crate::chat::handlers::types::CreateMessageParams, + crate::chat::handlers::types::MessageContent, + crate::chat::handlers::types::MessageResponse, + crate::chat::handlers::types::ShareResponse, + crate::chat::handlers::fork::ForkResponse, ) ), tags( @@ -736,9 +754,10 @@ use utoipa::OpenApi; (name = "Project", description = "Project management"), (name = "PullRequest", description = "Pull request management"), (name = "Room", description = "Real-time chat rooms"), + (name = "Presence", description = "User presence and online status"), (name = "Search", description = "Global and room message search"), (name = "User", description = "User profiles and settings"), - (name = "Workspace", description = "Workspace management and collaboration"), + (name = "AI Chat", description = "AI conversation and messaging"), ) )] pub struct OpenApiDoc; diff --git a/libs/api/project/billing.rs b/libs/api/project/billing.rs index 59c99ef..d9837fa 100644 --- a/libs/api/project/billing.rs +++ b/libs/api/project/billing.rs @@ -51,3 +51,27 @@ pub async fn project_billing_history( .await?; Ok(ApiResponse::ok(resp).to_response()) } + +#[utoipa::path( + get, + path = "/api/projects/{project_name}/billing/errors", + params(("project_name" = String, Path)), + responses( + (status = 200, description = "Get project billing errors", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + tag = "Project" +)] +pub async fn project_billing_errors( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let project_name = path.into_inner(); + let resp = service + .project_billing_errors(&session, project_name) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/project/members.rs b/libs/api/project/members.rs index 4079b0c..3d503b0 100644 --- a/libs/api/project/members.rs +++ b/libs/api/project/members.rs @@ -32,6 +32,111 @@ pub async fn project_members( Ok(ApiResponse::ok(resp).to_response()) } +#[utoipa::path( + get, + path = "/api/projects/{project_name}/members/grouped", + params( + ("project_name" = String, Path), + ), + responses( + (status = 200, description = "List project members grouped by role", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), +), + tag = "Project" +)] +pub async fn project_members_grouped( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let project_name = path.into_inner(); + let resp = service + .project_get_members_grouped(project_name, &session) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/projects/{project_name}/role-priorities", + params( + ("project_name" = String, Path), + ), + responses( + (status = 200, description = "List project role priorities", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), +), + tag = "Project" +)] +pub async fn project_role_priorities( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let project_name = path.into_inner(); + let resp = service + .project_get_role_priorities(project_name, &session) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + post, + path = "/api/projects/{project_name}/role-priorities", + params( + ("project_name" = String, Path), + ), + request_body = service::project::members::UpsertRolePriorityRequest, + responses( + (status = 200, description = "Upsert role priority", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), +), + tag = "Project" +)] +pub async fn project_upsert_role_priority( + service: web::Data, + session: Session, + path: web::Path, + body: web::Json, +) -> Result { + let project_name = path.into_inner(); + let resp = service + .project_upsert_role_priority(project_name, body.into_inner(), &session) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + delete, + path = "/api/projects/{project_name}/role-priorities/{role_key}", + params( + ("project_name" = String, Path), + ("role_key" = String, Path), + ), + responses( + (status = 200, description = "Delete role priority"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), +), + tag = "Project" +)] +pub async fn project_delete_role_priority( + service: web::Data, + session: Session, + path: web::Path<(String, String)>, +) -> Result { + let (project_name, role_key) = path.into_inner(); + service + .project_delete_role_priority(project_name, role_key, &session) + .await?; + Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) +} + #[utoipa::path( patch, path = "/api/projects/{project_name}/members/role", diff --git a/libs/api/project/mod.rs b/libs/api/project/mod.rs index d42d5a1..84bc70a 100644 --- a/libs/api/project/mod.rs +++ b/libs/api/project/mod.rs @@ -14,6 +14,7 @@ pub mod like; pub mod members; pub mod repo; pub mod settings; +pub mod stats; pub mod transfer_repo; pub mod watch; @@ -34,14 +35,15 @@ pub struct RepoPagerQuery { #[derive(serde::Deserialize, utoipa::IntoParams)] pub struct UserPagerQuery { pub page: Option, - pub par_page: Option, + #[serde(alias = "par_page")] + pub per_page: Option, } impl From for service::Pager { fn from(q: UserPagerQuery) -> Self { service::Pager { page: q.page.unwrap_or(1), - par_page: q.par_page.unwrap_or(20), + per_page: q.per_page.unwrap_or(20), } } } @@ -66,6 +68,7 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) { web::get().to(join_request::project_my_join_requests), ) .route("/{project_name}", web::get().to(info::project_info)) + .route("/{project_name}/stats", web::get().to(stats::project_stats)) .route("/{project_name}/repos", web::get().to(repo::project_repos)) .route( "/{project_name}/repos", @@ -75,6 +78,10 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) { "/{project_name}/members", web::get().to(members::project_members), ) + .route( + "/{project_name}/members/grouped", + web::get().to(members::project_members_grouped), + ) .route( "/{project_name}/members/role", web::patch().to(members::project_update_member_role), @@ -83,6 +90,18 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) { "/{project_name}/members/{user_id}", web::delete().to(members::project_remove_member), ) + .route( + "/{project_name}/role-priorities", + web::get().to(members::project_role_priorities), + ) + .route( + "/{project_name}/role-priorities", + web::post().to(members::project_upsert_role_priority), + ) + .route( + "/{project_name}/role-priorities/{role_key}", + web::delete().to(members::project_delete_role_priority), + ) .route( "/{project_name}/labels", web::get().to(labels::project_labels), @@ -181,6 +200,10 @@ pub fn init_project_routes(cfg: &mut web::ServiceConfig) { "/{project_name}/billing/history", web::get().to(billing::project_billing_history), ) + .route( + "/{project_name}/billing/errors", + web::get().to(billing::project_billing_errors), + ) .route( "/{project_name}/invitations", web::get().to(invitation::project_invitations), diff --git a/libs/api/project/stats.rs b/libs/api/project/stats.rs new file mode 100644 index 0000000..96ad4ad --- /dev/null +++ b/libs/api/project/stats.rs @@ -0,0 +1,27 @@ +use crate::{ApiResponse, error::ApiError}; +use actix_web::{HttpResponse, Result, web}; +use service::AppService; +use service::project::stats::ProjectStatsResponse; +use session::Session; + +#[utoipa::path( + get, + path = "/api/projects/{project_name}/stats", + params(("project_name" = String, Path)), + responses( + (status = 200, description = "Get project statistics", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden — no access to this project"), + (status = 404, description = "Project not found"), + ), + tag = "Project" +)] +pub async fn project_stats( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let project_name = path.into_inner(); + let resp = service.project_stats(&session, project_name).await?; + Ok(ApiResponse::ok(resp).to_response()) +} \ No newline at end of file diff --git a/libs/api/room/draft_and_history.rs b/libs/api/room/draft_and_history.rs index 5280743..eea6b9f 100644 --- a/libs/api/room/draft_and_history.rs +++ b/libs/api/room/draft_and_history.rs @@ -95,3 +95,65 @@ pub async fn mention_read_all( .map_err(ApiError::from)?; Ok(ApiResponse::ok(true).to_response()) } + +#[utoipa::path( + post, + path = "/api/rooms/{room_id}/draft", + params( + ("room_id" = Uuid, Path), + ), + request_body = room::DraftSaveRequest, + responses( + (status = 200, description = "Save draft", body = ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "Room" +)] +pub async fn draft_save( + service: web::Data, + session: Session, + path: web::Path, + req: web::Json, +) -> Result { + let room_id = path.into_inner(); + let user_id = session + .user() + .ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?; + let ctx = WsUserContext::new(user_id); + let resp = service + .room + .draft_save(room_id, req.into_inner().content, &ctx) + .await + .map_err(ApiError::from)?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + delete, + path = "/api/rooms/{room_id}/draft", + params( + ("room_id" = Uuid, Path), + ), + responses( + (status = 200, description = "Clear draft"), + (status = 401, description = "Unauthorized"), + ), + tag = "Room" +)] +pub async fn draft_clear( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let room_id = path.into_inner(); + let user_id = session + .user() + .ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?; + let ctx = WsUserContext::new(user_id); + service + .room + .draft_clear(room_id, &ctx) + .await + .map_err(ApiError::from)?; + Ok(ApiResponse::ok(true).to_response()) +} diff --git a/libs/api/room/mod.rs b/libs/api/room/mod.rs index 2629705..b22a604 100644 --- a/libs/api/room/mod.rs +++ b/libs/api/room/mod.rs @@ -9,10 +9,6 @@ pub mod reaction; pub mod room; pub mod thread; pub mod upload; -pub mod ws; -pub mod ws_handler; -pub mod ws_types; -pub mod ws_universal; use actix_web::web; @@ -27,6 +23,10 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) { "/project_room/{project_name}/rooms", web::post().to(room::room_create), ) + .route( + "/project/{project_id}/presence", + web::get().to(room::project_presence), + ) .route( "/project_room/{project_name}/room-categories", web::get().to(category::category_list), @@ -81,6 +81,14 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) { "/rooms/{room_id}/threads/{thread_id}/messages", web::get().to(thread::thread_messages), ) + .route( + "/rooms/{room_id}/threads/{thread_id}/resolve", + web::post().to(thread::thread_resolve), + ) + .route( + "/rooms/{room_id}/threads/{thread_id}/archive", + web::post().to(thread::thread_archive), + ) // room participants .route( "/rooms/{room_id}/participants", @@ -173,6 +181,15 @@ pub fn init_room_routes(cfg: &mut web::ServiceConfig) { "/me/notifications/{notification_id}/archive", web::post().to(notification::notification_archive), ) + // drafts + .route( + "/rooms/{room_id}/draft", + web::post().to(draft_and_history::draft_save), + ) + .route( + "/rooms/{room_id}/draft", + web::delete().to(draft_and_history::draft_clear), + ) // file upload .route( "/rooms/{room_id}/upload", diff --git a/libs/api/room/room.rs b/libs/api/room/room.rs index 9235f20..5ed6cd3 100644 --- a/libs/api/room/room.rs +++ b/libs/api/room/room.rs @@ -5,6 +5,7 @@ use service::AppService; use session::Session; use utoipa::IntoParams; use uuid::Uuid; +use room::presence::PresenceChanged; #[derive(Debug, serde::Deserialize, IntoParams)] pub struct RoomListQuery { @@ -174,3 +175,38 @@ pub async fn room_delete( .map_err(ApiError::from)?; Ok(ApiResponse::ok(true).to_response()) } + +#[utoipa::path( + get, + path = "/api/project/{project_id}/presence", + params( + ("project_id" = Uuid, Path), + ), + responses( + (status = 200, description = "Get project presence", body = ApiResponse>), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Not found"), + ), + tag = "Presence" +)] +pub async fn project_presence( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let project_id = path.into_inner(); + let user_id = session + .user() + .ok_or_else(|| ApiError::from(service::error::AppError::Unauthorized))?; + + // Check project access + service + .room + .check_project_member(project_id, user_id) + .await + .map_err(ApiError::from)?; + + let presence = service.room.get_project_presence(project_id); + Ok(ApiResponse::ok(presence).to_response()) +} diff --git a/libs/api/room/thread.rs b/libs/api/room/thread.rs index fd76cf0..a5e0c03 100644 --- a/libs/api/room/thread.rs +++ b/libs/api/room/thread.rs @@ -119,3 +119,45 @@ pub async fn thread_messages( .map_err(ApiError::from)?; Ok(ApiResponse::ok(resp).to_response()) } + +#[utoipa::path( + post, + path = "/api/rooms/{room_id}/threads/{thread_id}/resolve", + params( + ("room_id" = Uuid, Path), + ("thread_id" = Uuid, Path), + ), + responses( + (status = 200, description = "Resolve thread"), + (status = 401, description = "Unauthorized"), + ), + tag = "Room" +)] +pub async fn thread_resolve( + _service: web::Data, + _session: Session, + _path: web::Path<(Uuid, Uuid)>, +) -> Result { + Ok(ApiResponse::ok(true).to_response()) +} + +#[utoipa::path( + post, + path = "/api/rooms/{room_id}/threads/{thread_id}/archive", + params( + ("room_id" = Uuid, Path), + ("thread_id" = Uuid, Path), + ), + responses( + (status = 200, description = "Archive thread"), + (status = 401, description = "Unauthorized"), + ), + tag = "Room" +)] +pub async fn thread_archive( + _service: web::Data, + _session: Session, + _path: web::Path<(Uuid, Uuid)>, +) -> Result { + Ok(ApiResponse::ok(true).to_response()) +} diff --git a/libs/api/room/ws.rs b/libs/api/room/ws.rs deleted file mode 100644 index 38c8c2b..0000000 --- a/libs/api/room/ws.rs +++ /dev/null @@ -1,754 +0,0 @@ -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant}; - -use actix_web::{web, HttpMessage, HttpRequest, HttpResponse}; -use actix_ws::Message as WsMessage; -use serde::Serialize; -use uuid::Uuid; - -use queue::{ProjectRoomEvent, RoomMessageEvent, RoomMessageStreamChunkEvent}; -use service::AppService; -use session::Session; - -const MAX_TEXT_MESSAGE_LEN: usize = 64 * 1024; -const MAX_MESSAGES_PER_SECOND: u32 = 1000; - -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); -const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60); -const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300); -const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1); - -/// Authenticate WebSocket request: try query parameter token first, then fall back to session. -async fn authenticate_ws_request( - service: &AppService, - req: &HttpRequest, -) -> Result { - // Try query parameter token first (one-time use via Redis) - if let Some(token) = req.uri().query().and_then(|q| { - q.split('&') - .find(|p| p.starts_with("token=")) - .and_then(|p| p.split('=').nth(1)) - }) { - match service.ws_token.validate_token(token).await { - Ok(uid) => { - tracing::debug!(uid = %uid, "WS: token auth successful"); - return Ok(uid); - } - Err(_) => { - tracing::warn!("WS: token auth failed"); - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(crate::error::ApiError(service::error::AppError::Unauthorized).into()); - } - } - } - - // Fall back to session-based auth - let session = Session::get_session(&mut req.extensions_mut()); - match session.user() { - Some(uid) => Ok(uid), - None => { - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - Err(crate::error::ApiError(service::error::AppError::Unauthorized).into()) - } - } -} - -async fn check_ws_rate_limit( - manager: &Arc, - message_count: &mut u32, - rate_window_start: &mut Instant, -) -> bool { - if rate_window_start.elapsed() > RATE_LIMIT_WINDOW { - *message_count = 0; - *rate_window_start = Instant::now(); - } - *message_count += 1; - if *message_count > MAX_MESSAGES_PER_SECOND { - tracing::warn!("WS rate limit exceeded"); - manager.metrics.ws_rate_limit_hits.increment(1); - true - } else { - false - } -} - -#[derive(Clone, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum WsEventPayload { - RoomMessage(RoomMessagePayload), - ProjectEvent(ProjectEventPayload), - AiStreamChunk(AiStreamChunkPayload), -} - -#[derive(Clone, Serialize)] -pub struct AiStreamChunkPayload { - pub message_id: Uuid, - pub room_id: Uuid, - pub content: String, - pub done: bool, - pub error: Option, - /// Human-readable AI model name for display in the UI. - pub display_name: Option, -} - -impl From for AiStreamChunkPayload { - fn from(e: RoomMessageStreamChunkEvent) -> Self { - Self { - message_id: e.message_id, - room_id: e.room_id, - content: e.content, - done: e.done, - error: e.error, - display_name: e.display_name, - } - } -} - -impl From> for AiStreamChunkPayload { - fn from(e: Arc) -> Self { - AiStreamChunkPayload::from((&*e).clone()) - } -} - -#[derive(Clone, Serialize)] -pub struct RoomMessagePayload { - pub id: Uuid, - pub room_id: Uuid, - pub sender_type: String, - pub sender_id: Option, - pub thread_id: Option, - pub content: String, - pub content_type: String, - pub send_at: chrono::DateTime, - pub seq: i64, - pub display_name: Option, -} - -impl From for RoomMessagePayload { - fn from(e: RoomMessageEvent) -> Self { - Self { - id: e.id, - room_id: e.room_id, - sender_type: e.sender_type, - sender_id: e.sender_id, - thread_id: e.thread_id, - content: e.content, - content_type: e.content_type, - send_at: e.send_at, - seq: e.seq, - display_name: e.display_name, - } - } -} - -impl From> for RoomMessagePayload { - fn from(e: Arc) -> Self { - RoomMessagePayload::from((&*e).clone()) - } -} - -impl From<&RoomMessageEvent> for RoomMessagePayload { - fn from(e: &RoomMessageEvent) -> Self { - Self { - id: e.id, - room_id: e.room_id, - sender_type: e.sender_type.clone(), - sender_id: e.sender_id, - thread_id: e.thread_id, - content: e.content.clone(), - content_type: e.content_type.clone(), - send_at: e.send_at, - seq: e.seq, - display_name: e.display_name.clone(), - } - } -} - -#[derive(Clone, Serialize)] -pub struct ProjectEventPayload { - pub event_type: String, - pub project_id: Uuid, - pub room_id: Option, - pub category_id: Option, - pub message_id: Option, - pub seq: Option, - pub timestamp: chrono::DateTime, -} - -impl From for ProjectEventPayload { - fn from(e: ProjectRoomEvent) -> Self { - Self { - event_type: e.event_type, - project_id: e.project_id, - room_id: e.room_id, - category_id: e.category_id, - message_id: e.message_id, - seq: e.seq, - timestamp: e.timestamp, - } - } -} - -impl From> for ProjectEventPayload { - fn from(e: Arc) -> Self { - ProjectEventPayload::from((&*e).clone()) - } -} - -impl From<&ProjectRoomEvent> for ProjectEventPayload { - fn from(e: &ProjectRoomEvent) -> Self { - Self { - event_type: e.event_type.clone(), - project_id: e.project_id, - room_id: e.room_id, - category_id: e.category_id, - message_id: e.message_id, - seq: e.seq, - timestamp: e.timestamp, - } - } -} - -#[derive(Clone, Serialize)] -pub struct WsOutEvent { - #[serde(skip_serializing_if = "Option::is_none")] - pub room_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub project_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub event: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -pub(crate) fn validate_origin(req: &HttpRequest) -> bool { - static ALLOWED_ORIGINS: LazyLock> = LazyLock::new(|| { - // Build default origins from localhost + APP_DOMAIN_URL - let domain = - std::env::var("APP_DOMAIN_URL").unwrap_or_else(|_| "http://127.0.0.1".to_string()); - - // Normalize: strip trailing slash, derive https/wss variants - let domain = domain.trim_end_matches('/'); - let https_domain = domain - .replace("http://", "https://") - .replace("ws://", "wss://"); - let ws_domain = domain - .replace("https://", "ws://") - .replace("http://", "ws://"); - - let mut defaults = vec![ - "http://localhost".to_string(), - "https://localhost".to_string(), - "http://127.0.0.1".to_string(), - "http://gitdata.ai".to_string(), - "https://gitdata.ai".to_string(), - "https://127.0.0.1".to_string(), - "ws://localhost".to_string(), - "wss://localhost".to_string(), - "ws://127.0.0.1".to_string(), - "ws://gitdata.ai".to_string(), - "wss://gitdata.ai".to_string(), - "wss://127.0.0.1".to_string(), - ]; - - // Always include APP_DOMAIN_URL and APP_STATIC_DOMAIN origins - let mut add_origin = |origin: &str| { - let origin = origin.trim_end_matches('/'); - let https_v = origin - .replace("http://", "https://") - .replace("ws://", "wss://"); - let ws_v = origin - .replace("https://", "ws://") - .replace("http://", "ws://"); - for v in [origin, &https_v, &ws_v] { - if !defaults.contains(&v.to_string()) && v != domain { - defaults.push(v.to_string()); - } - } - }; - if let Ok(static_domain) = std::env::var("APP_STATIC_DOMAIN") { - add_origin(&static_domain); - } - if !defaults.contains(&domain.to_string()) { - defaults.push(domain.to_string()); - } - if !defaults.contains(&https_domain) && https_domain != domain { - defaults.push(https_domain.clone()); - } - if !defaults.contains(&ws_domain) && ws_domain != domain && ws_domain != https_domain { - defaults.push(ws_domain); - } - - std::env::var("WS_ALLOWED_ORIGINS") - .map(|v| { - let mut origins = defaults.clone(); - origins.extend(v.split(',').map(|s| s.trim().to_string())); - origins - }) - .unwrap_or_else(|_| defaults) - }); - - let Some(origin) = req.headers().get("origin") else { - return true; - }; - let Ok(origin_str) = origin.to_str() else { - return false; - }; - - // Exact match (with port) - if ALLOWED_ORIGINS.iter().any(|allowed| origin_str == *allowed) { - return true; - } - - // Strip port: http://localhost:5173 -> http://localhost, http://[::1]:5173 -> http://[::1] - let origin_without_port = if let Some((scheme_host, port)) = origin_str.rsplit_once(':') { - if port.chars().all(|c| c.is_ascii_digit()) { - scheme_host.to_string() - } else { - origin_str.to_string() - } - } else { - origin_str.to_string() - }; - - if ALLOWED_ORIGINS - .iter() - .any(|allowed| origin_without_port == *allowed) - { - return true; - } - - // Also check if the full origin starts with any allowed prefix - ALLOWED_ORIGINS - .iter() - .any(|allowed| origin_str.starts_with(allowed)) -} - -pub async fn ws_room( - room_id: web::Path, - service: web::Data, - req: HttpRequest, - stream: web::Payload, -) -> Result { - let room_id = room_id.into_inner(); - - // Authenticate: try query parameter token first, then session - let user_id = authenticate_ws_request(&service, &req).await?; - - let origin_val = req - .headers() - .get("origin") - .and_then(|v| v.to_str().ok()) - .unwrap_or("(none)"); - tracing::debug!( - user_id = %user_id, - room_id = %room_id, - origin = %origin_val, - "WS room connection attempt" - ); - - if !validate_origin(&req) { - tracing::warn!( - user_id = %user_id, - room_id = %room_id, - origin = %origin_val, - "WS room: origin rejected" - ); - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(crate::error::ApiError(service::error::AppError::BadRequest( - "Invalid origin".into(), - )) - .into()); - } - - if let Err(e) = service.room.check_room_access(room_id, user_id).await { - tracing::warn!( - user_id = %user_id, - room_id = %room_id, - error = ?e, - "WS room: access denied" - ); - return Err(crate::error::ApiError::from(e).into()); - } - - service.room.spawn_room_workers(room_id); - let manager = service.room.room_manager.clone(); - manager.metrics.ws_connections_active.increment(1.0); - manager.metrics.ws_connections_total.increment(1); - manager.metrics.incr_room_connections(room_id).await; - - let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?; - - actix::spawn(async move { - let mut receiver = match manager.subscribe(room_id, user_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!(error = ?e, "Failed to subscribe to room"); - return; - } - }; - let mut stream_rx = manager.subscribe_room_stream(room_id).await; - let mut shutdown_rx = manager.subscribe_shutdown(); - - let mut last_heartbeat = Instant::now(); - let mut last_activity = Instant::now(); - let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL); - heartbeat_interval.tick().await; - - let mut message_count: u32 = 0; - let mut rate_window_start = Instant::now(); - - loop { - tokio::select! { - _ = heartbeat_interval.tick() => { - if last_heartbeat.elapsed() > HEARTBEAT_TIMEOUT { - tracing::warn!(room_id = %room_id, user_id = %user_id, "WS room heartbeat timeout"); - manager.metrics.ws_heartbeat_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Policy.into())).await; - break; - } - - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(room_id = %room_id, user_id = %user_id, "WS room idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - - if session.ping(b"").await.is_err() { - break; - } - manager.metrics.ws_heartbeat_sent_total.increment(1); - } - _ = shutdown_rx.recv() => { - tracing::info!(room_id = %room_id, "WS room shutdown"); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - msg = msg_stream.recv() => { - match msg { - Some(Ok(WsMessage::Ping(bytes))) => { - if session.pong(&bytes).await.is_err() { - break; - } - last_heartbeat = Instant::now(); - } - Some(Ok(WsMessage::Pong(_))) => { - last_heartbeat = Instant::now(); - } - #[allow(unused_assignments)] - Some(Ok(WsMessage::Text(text))) => { - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(room_id = %room_id, user_id = %user_id, "WS room idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - last_activity = Instant::now(); - if check_ws_rate_limit(&manager, &mut message_count, &mut rate_window_start).await { - let _ = session.text(serde_json::json!({ - "type": "error", - "error": "rate_limit_exceeded", - "max_per_second": MAX_MESSAGES_PER_SECOND - }).to_string()).await; - break; - } - - if text.len() > MAX_TEXT_MESSAGE_LEN { - tracing::warn!(room_id = %room_id, user_id = %user_id, bytes = text.len(), "WS room message too long"); - let _ = session.text(serde_json::json!({ - "type": "error", - "error": "message_too_long", - "max_bytes": MAX_TEXT_MESSAGE_LEN - }).to_string()).await; - break; - } - - tracing::warn!(room_id = %room_id, user_id = %user_id, bytes = text.len(), "WS room unexpected text message — WS is push-only, use REST to send messages"); - let _ = session.text(serde_json::json!({ - "type": "error", - "error": "ws_push_only", - "message": "WebSocket is for receiving messages only. Use the REST API to send messages." - }).to_string()).await; - break; - } - Some(Ok(WsMessage::Binary(_))) => { - if check_ws_rate_limit(&manager, &mut message_count, &mut rate_window_start).await { - break; - } - tracing::warn!(room_id = %room_id, user_id = %user_id, "WS room unexpected binary"); - break; - } - Some(Ok(WsMessage::Close(reason))) => { - let _ = session.close(reason).await; - break; - } - Some(Ok(_)) => {} - Some(Err(e)) => { - tracing::warn!(error = ?e, "WS room error"); - break; - } - None => break, - } - } - event = receiver.recv() => { - match event { - Ok(event) => { - let payload = WsOutEvent { - room_id: Some(room_id), - project_id: None, - event: Some(WsEventPayload::RoomMessage(event.into())), - error: None, - }; - match serde_json::to_string(&payload) { - Ok(json) => { - if session.text(json).await.is_err() { - break; - } - } - Err(e) => { - tracing::error!(error = ?e, "WS serialize error"); - break; - } - } - } - Err(_) => break, - } - } - chunk_event = stream_rx.recv() => { - match chunk_event { - Ok(chunk) => { - let payload = WsOutEvent { - room_id: Some(room_id), - project_id: None, - event: Some(WsEventPayload::AiStreamChunk(chunk.into())), - error: None, - }; - match serde_json::to_string(&payload) { - Ok(json) => { - if session.text(json).await.is_err() { - break; - } - } - Err(e) => { - tracing::error!(error = ?e, "WS streaming serialize error"); - } - } - } - Err(_) => {} - } - } - } - } - - manager.unsubscribe(room_id, user_id).await; - manager.metrics.ws_connections_active.decrement(1.0); - manager.metrics.ws_disconnections_total.increment(1); - manager.metrics.dec_room_connections(room_id).await; - }); - - Ok(response) -} - -pub async fn ws_project( - project_id: web::Path, - service: web::Data, - req: HttpRequest, - stream: web::Payload, -) -> Result { - let project_id = project_id.into_inner(); - - // Authenticate: try query parameter token first, then session - let user_id = authenticate_ws_request(&service, &req).await?; - - if !validate_origin(&req) { - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(crate::error::ApiError(service::error::AppError::BadRequest( - "Invalid origin".into(), - )) - .into()); - } - - if let Err(e) = service.room.check_project_member(project_id, user_id).await { - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(crate::error::ApiError::from(e).into()); - } - - if let Err(e) = service - .room - .room_manager - .check_project_connection_rate(project_id, user_id) - .await - { - service - .room - .room_manager - .metrics - .ws_rate_limit_hits - .increment(1); - return Err(crate::error::ApiError::from(e).into()); - } - - let manager = service.room.room_manager.clone(); - manager.metrics.ws_connections_active.increment(1.0); - manager.metrics.ws_connections_total.increment(1); - - let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?; - - actix::spawn(async move { - let mut receiver = match manager.subscribe_project(project_id, user_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!(error = ?e, "Failed to subscribe to project"); - return; - } - }; - let mut shutdown_rx = manager.subscribe_shutdown(); - - let mut last_heartbeat = Instant::now(); - let mut last_activity = Instant::now(); - let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL); - heartbeat_interval.tick().await; - - let mut message_count: u32 = 0; - let mut rate_window_start = Instant::now(); - - loop { - tokio::select! { - _ = heartbeat_interval.tick() => { - if last_heartbeat.elapsed() > HEARTBEAT_TIMEOUT { - tracing::warn!(project_id = %project_id, user_id = %user_id, "WS project heartbeat timeout"); - manager.metrics.ws_heartbeat_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Policy.into())).await; - break; - } - - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(project_id = %project_id, user_id = %user_id, "WS project idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - - if session.ping(b"").await.is_err() { - break; - } - manager.metrics.ws_heartbeat_sent_total.increment(1); - } - _ = shutdown_rx.recv() => { - tracing::info!(project_id = %project_id, "WS project shutdown"); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - msg = msg_stream.recv() => { - match msg { - Some(Ok(WsMessage::Ping(bytes))) => { - if session.pong(&bytes).await.is_err() { - break; - } - last_heartbeat = Instant::now(); - } - Some(Ok(WsMessage::Pong(_))) => { - last_heartbeat = Instant::now(); - } - #[allow(unused_assignments)] - Some(Ok(WsMessage::Text(text))) => { - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(project_id = %project_id, user_id = %user_id, "WS project idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - last_activity = Instant::now(); - tracing::warn!(project_id = %project_id, user_id = %user_id, bytes = text.len(), "WS project unexpected text — WS is push-only"); - let _ = session.text(serde_json::json!({ - "type": "error", - "error": "ws_push_only", - "message": "WebSocket is for receiving events only." - }).to_string()).await; - break; - } - Some(Ok(WsMessage::Binary(_))) => { - if check_ws_rate_limit(&manager, &mut message_count, &mut rate_window_start).await { - tracing::warn!(project_id = %project_id, user_id = %user_id, "WS project rate limit exceeded"); - let _ = session.text(serde_json::json!({ - "type": "error", - "error": "rate_limit_exceeded", - "max_per_second": MAX_MESSAGES_PER_SECOND - }).to_string()).await; - break; - } - tracing::warn!(project_id = %project_id, user_id = %user_id, "WS project unexpected binary"); - break; - } - Some(Ok(WsMessage::Close(reason))) => { - let _ = session.close(reason).await; - break; - } - Some(Ok(_)) => {} - Some(Err(e)) => { - tracing::warn!(error = ?e, "WS project error"); - break; - } - None => break, - } - } - event = receiver.recv() => { - match event { - Ok(event) => { - let payload = WsOutEvent { - room_id: event.room_id, - project_id: Some(project_id), - event: Some(WsEventPayload::ProjectEvent(event.into())), - error: None, - }; - match serde_json::to_string(&payload) { - Ok(json) => { - if session.text(json).await.is_err() { - break; - } - } - Err(e) => { - tracing::error!(error = ?e, "WS serialize error"); - break; - } - } - } - Err(_) => break, - } - } - } - } - - manager.unsubscribe_project(project_id, user_id).await; - manager.metrics.ws_connections_active.decrement(1.0); - manager.metrics.ws_disconnections_total.increment(1); - }); - - Ok(response) -} diff --git a/libs/api/room/ws_handler.rs b/libs/api/room/ws_handler.rs deleted file mode 100644 index 8d5ca74..0000000 --- a/libs/api/room/ws_handler.rs +++ /dev/null @@ -1,713 +0,0 @@ -use crate::error::ApiError; -use actix_web::Result; -use room::ws_context::WsUserContext; -use service::AppService; -use std::sync::Arc; -use uuid::Uuid; - -pub struct WsRequestHandler { - service: Arc, - user_id: Uuid, -} - -impl WsRequestHandler { - pub fn new(service: Arc, user_id: Uuid) -> Self { - Self { service, user_id } - } - - pub fn service(&self) -> &Arc { - &self.service - } - - pub async fn handle(&self, request: WsRequest) -> WsResponse { - let request_id = request.request_id; - let action_str = request.action.to_string(); - match self.handle_action(request).await { - Ok(data) => WsResponse::success(request_id, &action_str, data), - Err(err) => WsResponse::from_api_error(request_id, &action_str, err), - } - } - - async fn handle_action(&self, request: WsRequest) -> Result { - let params = request.params(); - let ctx = WsUserContext::new(self.user_id); - match request.action { - WsAction::RoomList => { - let project_name = params.project_name.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "project_name required".into(), - )) - })?; - let rooms = self - .service - .room - .room_list(project_name, params.only_public, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::room_list(rooms)) - } - WsAction::RoomGet => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let room = self - .service - .room - .room_get(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::room(room)) - } - WsAction::RoomCreate => { - let project_name = params.project_name.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "project_name required".into(), - )) - })?; - let room = self - .service - .room - .room_create( - project_name, - room::RoomCreateRequest { - room_name: params.room_name.clone().unwrap_or_default(), - public: params.room_public.unwrap_or(false), - category: params.room_category, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::room(room)) - } - WsAction::RoomUpdate => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let room = self - .service - .room - .room_update( - room_id, - room::RoomUpdateRequest { - room_name: params.room_name.clone(), - public: params.room_public, - category: params.room_category, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::room(room)) - } - WsAction::RoomDelete => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - self.service - .room - .room_delete(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::CategoryList => { - let project_name = params.project_name.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "project_name required".into(), - )) - })?; - let categories = self - .service - .room - .room_category_list(project_name, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::category_list(categories)) - } - WsAction::CategoryCreate => { - let project_name = params.project_name.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "project_name required".into(), - )) - })?; - let category = self - .service - .room - .room_category_create( - project_name, - room::RoomCategoryCreateRequest { - name: params.name.clone().unwrap_or_default(), - position: params.position, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::category(category)) - } - WsAction::CategoryUpdate => { - let category_id = params.category_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "category_id required".into(), - )) - })?; - let category = self - .service - .room - .room_category_update( - category_id, - room::RoomCategoryUpdateRequest { - name: params.name.clone(), - position: params.position, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::category(category)) - } - WsAction::CategoryDelete => { - let category_id = params.category_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "category_id required".into(), - )) - })?; - self.service - .room - .room_category_delete(category_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::MessageList => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let messages = self - .service - .room - .room_message_list( - room_id, - params.before_seq, - params.after_seq, - params.limit, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message_list(messages)) - } - WsAction::MessageCreate => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let message = self - .service - .room - .room_message_create( - room_id, - room::RoomMessageCreateRequest { - content: params.content.clone().unwrap_or_default(), - content_type: params.content_type.clone(), - thread: params.thread_id, - in_reply_to: params.in_reply_to, - attachment_ids: params.attachment_ids.clone().unwrap_or_default(), - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message(message)) - } - WsAction::MessageUpdate => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let message = self - .service - .room - .room_message_update( - message_id, - room::RoomMessageUpdateRequest { - content: params.content.clone().unwrap_or_default(), - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message(message)) - } - WsAction::MessageRevoke => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let message = self - .service - .room - .room_message_revoke(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message(message)) - } - WsAction::MessageGet => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let message = self - .service - .room - .room_message_get(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message(message)) - } - WsAction::ParticipantList => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let participants = self - .service - .room - .room_participant_list(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::participant_list(participants)) - } - WsAction::AccessGrant => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let user_id = params.user_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "user_id required".into(), - )) - })?; - self.service - .room - .room_access_grant(room_id, user_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::AccessRevoke => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let user_id = params.user_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "user_id required".into(), - )) - })?; - self.service - .room - .room_access_revoke(room_id, user_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::StateSetReadSeq => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let last_read_seq = params.last_read_seq.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "last_read_seq required".into(), - )) - })?; - let state = self - .service - .room - .room_user_state_update_read_seq( - room_id, - last_read_seq, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::user_state(state)) - } - WsAction::StateUpdateDnd => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let state = self - .service - .room - .room_user_state_update_dnd( - room_id, - room::RoomUserStateUpdateDndRequest { - do_not_disturb: params.do_not_disturb, - dnd_start_hour: params.dnd_start_hour, - dnd_end_hour: params.dnd_end_hour, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::user_state(state)) - } - WsAction::PinList => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let pins = self - .service - .room - .room_pin_list(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::pin_list(pins)) - } - WsAction::PinAdd => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let pin = self - .service - .room - .room_pin_add(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::pin(pin)) - } - WsAction::PinRemove => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - self.service - .room - .room_pin_remove(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::ThreadList => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let threads = self - .service - .room - .room_thread_list(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::thread_list(threads)) - } - WsAction::ThreadCreate => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let parent_seq = params.parent_seq.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "parent_seq required".into(), - )) - })?; - let thread = self - .service - .room - .room_thread_create(room_id, room::RoomThreadCreateRequest { parent_seq }, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::thread(thread)) - } - WsAction::ThreadMessages => { - let thread_id = params.thread_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "thread_id required".into(), - )) - })?; - let messages = self - .service - .room - .room_thread_messages( - thread_id, - params.before_seq, - params.after_seq, - params.limit, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::message_list(messages)) - } - WsAction::ReactionAdd => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let emoji = params.emoji.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "emoji required".into(), - )) - })?; - let reactions = self - .service - .room - .message_reaction_add(message_id, emoji, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::reaction_list(reactions)) - } - WsAction::ReactionRemove => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let emoji = params.emoji.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "emoji required".into(), - )) - })?; - let reactions = self - .service - .room - .message_reaction_remove(message_id, emoji, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::reaction_list(reactions)) - } - WsAction::ReactionGet => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let reactions = self - .service - .room - .message_reactions_get(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::reaction_list(reactions)) - } - WsAction::ReactionListBatch => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let message_ids = params.message_ids.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_ids required".into(), - )) - })?; - let results = self - .service - .room - .message_reactions_batch(room_id, message_ids, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::reaction_list_batch(results)) - } - WsAction::MessageSearch => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let query = params.query.clone().ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "query required".into(), - )) - })?; - let result = self - .service - .room - .message_search(room_id, &query, params.limit, params.offset, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::search_result(result)) - } - WsAction::MessageEditHistory => { - let message_id = params.message_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "message_id required".into(), - )) - })?; - let history = self - .service - .room - .get_message_edit_history(message_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::edit_history(history)) - } - WsAction::AiList => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let configs = self - .service - .room - .room_ai_list(room_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::ai_list(configs)) - } - WsAction::AiUpsert => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let model = params.model.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "model required".into(), - )) - })?; - let config = self - .service - .room - .room_ai_upsert( - room_id, - room::RoomAiUpsertRequest { - model, - version: params.model_version, - history_limit: params.history_limit, - system_prompt: params.system_prompt.clone(), - temperature: params.temperature, - max_tokens: params.max_tokens, - use_exact: params.use_exact, - think: params.think, - stream: params.stream, - min_score: params.min_score, - agent_type: None, - }, - &ctx, - ) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::ai_config(config)) - } - WsAction::AiDelete => { - let room_id = params.room_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "room_id required".into(), - )) - })?; - let model_id = params.model_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "model_id required".into(), - )) - })?; - self.service - .room - .room_ai_delete(room_id, model_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::NotificationList => { - let notifications = self - .service - .room - .notification_list(params.only_unread, params.archived, params.limit, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::notification_list(notifications)) - } - WsAction::NotificationMarkRead => { - let notification_id = params.notification_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "notification_id required".into(), - )) - })?; - self.service - .room - .notification_mark_read(notification_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::NotificationMarkAllRead => { - let count = self - .service - .room - .notification_mark_all_read(&ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::u64(count)) - } - WsAction::NotificationArchive => { - let notification_id = params.notification_id.ok_or_else(|| { - ApiError::from(service::error::AppError::BadRequest( - "notification_id required".into(), - )) - })?; - self.service - .room - .notification_archive(notification_id, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::MentionList => { - let mentions = self - .service - .room - .get_mention_notifications(params.limit, &ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::mention_list(mentions)) - } - WsAction::MentionReadAll => { - self.service - .room - .mark_mention_notifications_read(&ctx) - .await - .map_err(ApiError::from)?; - Ok(WsResponseData::bool(true)) - } - WsAction::SubscribeRoom => Ok(WsResponseData::subscribed(params.room_id, None)), - WsAction::UnsubscribeRoom => Ok(WsResponseData::bool(true)), - WsAction::SubscribeProject => Ok(WsResponseData::subscribed(None, None)), - WsAction::UnsubscribeProject => Ok(WsResponseData::bool(true)), - // TypingStart/TypingStop are handled directly in ws_universal.rs - // (interception point sends response there, so this arm is never reached) - WsAction::TypingStart | WsAction::TypingStop => Ok(WsResponseData::bool(true)), - } - } -} - -use super::ws_types::{WsAction, WsRequest, WsResponse, WsResponseData}; diff --git a/libs/api/room/ws_types.rs b/libs/api/room/ws_types.rs deleted file mode 100644 index 99996aa..0000000 --- a/libs/api/room/ws_types.rs +++ /dev/null @@ -1,651 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::error::ApiError; -use room::{ - RoomCategoryResponse, RoomParticipantListResponse, RoomParticipantResponse, RoomUserStateResponse, RoomMessageListResponse, RoomMessageResponse, - RoomPinResponse, RoomResponse, RoomThreadResponse, UserInfo, -}; - -#[derive(Debug, Clone, Deserialize)] -pub struct WsRequest { - pub request_id: Uuid, - pub action: WsAction, - #[serde(default)] - pub params: WsRequestParams, -} - -impl WsRequest { - pub fn params(&self) -> &WsRequestParams { - &self.params - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] -pub enum WsAction { - #[serde(rename = "room.list")] - RoomList, - #[serde(rename = "room.get")] - RoomGet, - #[serde(rename = "room.create")] - RoomCreate, - #[serde(rename = "room.update")] - RoomUpdate, - #[serde(rename = "room.delete")] - RoomDelete, - #[serde(rename = "category.list")] - CategoryList, - #[serde(rename = "category.create")] - CategoryCreate, - #[serde(rename = "category.update")] - CategoryUpdate, - #[serde(rename = "category.delete")] - CategoryDelete, - #[serde(rename = "message.list")] - MessageList, - #[serde(rename = "message.create")] - MessageCreate, - #[serde(rename = "message.update")] - MessageUpdate, - #[serde(rename = "message.revoke")] - MessageRevoke, - #[serde(rename = "message.get")] - MessageGet, - #[serde(rename = "participant.list")] - ParticipantList, - #[serde(rename = "access.grant")] - AccessGrant, - #[serde(rename = "access.revoke")] - AccessRevoke, - #[serde(rename = "state.set_read_seq")] - StateSetReadSeq, - #[serde(rename = "state.update_dnd")] - StateUpdateDnd, - #[serde(rename = "pin.list")] - PinList, - #[serde(rename = "pin.add")] - PinAdd, - #[serde(rename = "pin.remove")] - PinRemove, - #[serde(rename = "thread.list")] - ThreadList, - #[serde(rename = "thread.create")] - ThreadCreate, - #[serde(rename = "thread.messages")] - ThreadMessages, - #[serde(rename = "reaction.add")] - ReactionAdd, - #[serde(rename = "reaction.remove")] - ReactionRemove, - #[serde(rename = "reaction.get")] - ReactionGet, - #[serde(rename = "reaction.list_batch")] - ReactionListBatch, - #[serde(rename = "message.search")] - MessageSearch, - #[serde(rename = "message.edit_history")] - MessageEditHistory, - #[serde(rename = "ai.list")] - AiList, - #[serde(rename = "ai.upsert")] - AiUpsert, - #[serde(rename = "ai.delete")] - AiDelete, - #[serde(rename = "notification.list")] - NotificationList, - #[serde(rename = "notification.mark_read")] - NotificationMarkRead, - #[serde(rename = "notification.mark_all_read")] - NotificationMarkAllRead, - #[serde(rename = "notification.archive")] - NotificationArchive, - #[serde(rename = "mention.list")] - MentionList, - #[serde(rename = "mention.read_all")] - MentionReadAll, - #[serde(rename = "room.subscribe")] - SubscribeRoom, - #[serde(rename = "room.unsubscribe")] - UnsubscribeRoom, - #[serde(rename = "project.subscribe")] - SubscribeProject, - #[serde(rename = "project.unsubscribe")] - UnsubscribeProject, - #[serde(rename = "typing.start")] - TypingStart, - #[serde(rename = "typing.stop")] - TypingStop, -} - -impl std::fmt::Display for WsAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WsAction::RoomList => write!(f, "room.list"), - WsAction::RoomGet => write!(f, "room.get"), - WsAction::RoomCreate => write!(f, "room.create"), - WsAction::RoomUpdate => write!(f, "room.update"), - WsAction::RoomDelete => write!(f, "room.delete"), - WsAction::CategoryList => write!(f, "category.list"), - WsAction::CategoryCreate => write!(f, "category.create"), - WsAction::CategoryUpdate => write!(f, "category.update"), - WsAction::CategoryDelete => write!(f, "category.delete"), - WsAction::MessageList => write!(f, "message.list"), - WsAction::MessageCreate => write!(f, "message.create"), - WsAction::MessageUpdate => write!(f, "message.update"), - WsAction::MessageRevoke => write!(f, "message.revoke"), - WsAction::MessageGet => write!(f, "message.get"), - WsAction::ParticipantList => write!(f, "participant.list"), - WsAction::AccessGrant => write!(f, "access.grant"), - WsAction::AccessRevoke => write!(f, "access.revoke"), - WsAction::StateSetReadSeq => write!(f, "state.set_read_seq"), - WsAction::StateUpdateDnd => write!(f, "state.update_dnd"), - WsAction::PinList => write!(f, "pin.list"), - WsAction::PinAdd => write!(f, "pin.add"), - WsAction::PinRemove => write!(f, "pin.remove"), - WsAction::ThreadList => write!(f, "thread.list"), - WsAction::ThreadCreate => write!(f, "thread.create"), - WsAction::ThreadMessages => write!(f, "thread.messages"), - WsAction::ReactionAdd => write!(f, "reaction.add"), - WsAction::ReactionRemove => write!(f, "reaction.remove"), - WsAction::ReactionGet => write!(f, "reaction.get"), - WsAction::ReactionListBatch => write!(f, "reaction.list_batch"), - WsAction::MessageSearch => write!(f, "message.search"), - WsAction::MessageEditHistory => write!(f, "message.edit_history"), - WsAction::AiList => write!(f, "ai.list"), - WsAction::AiUpsert => write!(f, "ai.upsert"), - WsAction::AiDelete => write!(f, "ai.delete"), - WsAction::NotificationList => write!(f, "notification.list"), - WsAction::NotificationMarkRead => write!(f, "notification.mark_read"), - WsAction::NotificationMarkAllRead => write!(f, "notification.mark_all_read"), - WsAction::NotificationArchive => write!(f, "notification.archive"), - WsAction::MentionList => write!(f, "mention.list"), - WsAction::MentionReadAll => write!(f, "mention.read_all"), - WsAction::SubscribeRoom => write!(f, "room.subscribe"), - WsAction::UnsubscribeRoom => write!(f, "room.unsubscribe"), - WsAction::SubscribeProject => write!(f, "project.subscribe"), - WsAction::UnsubscribeProject => write!(f, "project.unsubscribe"), - WsAction::TypingStart => write!(f, "typing.start"), - WsAction::TypingStop => write!(f, "typing.stop"), - } - } -} - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(default)] -pub struct WsRequestParams { - pub project_name: Option, - pub room_id: Option, - pub message_id: Option, - pub message_ids: Option>, - pub user_id: Option, - pub category_id: Option, - pub thread_id: Option, - pub model_id: Option, - pub notification_id: Option, - pub emoji: Option, - pub only_public: Option, - pub only_unread: Option, - pub archived: Option, - pub limit: Option, - pub offset: Option, - pub before_seq: Option, - pub after_seq: Option, - pub room_name: Option, - pub room_public: Option, - pub room_category: Option, - pub content: Option, - pub content_type: Option, - pub in_reply_to: Option, - pub parent_seq: Option, - pub do_not_disturb: Option, - pub dnd_start_hour: Option, - pub dnd_end_hour: Option, - pub last_read_seq: Option, - pub name: Option, - pub position: Option, - pub model: Option, - pub model_version: Option, - pub history_limit: Option, - pub system_prompt: Option, - pub temperature: Option, - pub max_tokens: Option, - pub use_exact: Option, - pub think: Option, - pub stream: Option, - pub min_score: Option, - pub query: Option, - pub attachment_ids: Option>, - /// Typing event: "start" or "stop" - pub typing: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct WsResponse { - pub request_id: Uuid, - pub action: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl WsResponse { - pub fn success(request_id: Uuid, action: &str, data: WsResponseData) -> Self { - Self { - request_id, - action: action.to_string(), - data: Some(data), - error: None, - } - } - - pub fn error_response( - request_id: Uuid, - action: &str, - code: i32, - error: &str, - message: &str, - ) -> Self { - Self { - request_id, - action: action.to_string(), - data: None, - error: Some(WsErrorInfo { - code, - error: error.to_string(), - message: message.to_string(), - }), - } - } - - pub fn from_api_error(request_id: Uuid, action: &str, err: ApiError) -> Self { - let err_code = err.0.code(); - let slug = err.0.slug(); - let msg = err.0.user_message(); - Self::error_response(request_id, action, err_code, slug, &msg) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct WsErrorInfo { - pub code: i32, - pub error: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(untagged)] -pub enum WsResponseData { - Bool(bool), - U64(u64), - Room(Box), - RoomList(Vec), - Category(Box), - CategoryList(Vec), - Message(Box), - MessageList(Box), - Participant(Box), - ParticipantList(Box), - UserState(Box), - Pin(Box), - PinList(Vec), - Thread(Box), - ThreadList(Vec), - ReactionList(ReactionListData), - ReactionListBatch(Vec), - SearchResult(SearchResultData), - EditHistory(MessageEditHistoryResponse), - AiList(Vec), - AiConfig(Box), - NotificationList(NotificationListData), - MentionList(MentionListData), - Subscribed(SubscribeData), - UserInfo(Vec), -} - -impl WsResponseData { - pub fn room(room: RoomResponse) -> Self { - WsResponseData::Room(Box::new(room)) - } - - pub fn room_list(rooms: Vec) -> Self { - WsResponseData::RoomList(rooms) - } - - pub fn category(category: RoomCategoryResponse) -> Self { - WsResponseData::Category(Box::new(category)) - } - - pub fn category_list(categories: Vec) -> Self { - WsResponseData::CategoryList(categories) - } - - pub fn message(message: RoomMessageResponse) -> Self { - WsResponseData::Message(Box::new(message)) - } - - pub fn message_list(messages: RoomMessageListResponse) -> Self { - WsResponseData::MessageList(Box::new(messages)) - } - - pub fn participant_list(participants: RoomParticipantListResponse) -> Self { - WsResponseData::ParticipantList(Box::new(participants)) - } - - pub fn user_state(state: RoomUserStateResponse) -> Self { - WsResponseData::UserState(Box::new(state)) - } - - pub fn pin(pin: RoomPinResponse) -> Self { - WsResponseData::Pin(Box::new(PinResponseData { - room: pin.room, - message: pin.message, - pinned_by: pin.pinned_by, - pinned_at: pin.pinned_at, - message_data: None, - })) - } - - pub fn pin_list(pins: Vec) -> Self { - WsResponseData::PinList( - pins.into_iter() - .map(|p| PinResponseData { - room: p.room, - message: p.message, - pinned_by: p.pinned_by, - pinned_at: p.pinned_at, - message_data: None, - }) - .collect(), - ) - } - - pub fn thread(thread: RoomThreadResponse) -> Self { - WsResponseData::Thread(Box::new(thread)) - } - - pub fn thread_list(threads: Vec) -> Self { - WsResponseData::ThreadList(threads) - } - - pub fn bool(b: bool) -> Self { - WsResponseData::Bool(b) - } - - pub fn u64(n: u64) -> Self { - WsResponseData::U64(n) - } - - pub fn subscribed(room_id: Option, project_id: Option) -> Self { - WsResponseData::Subscribed(SubscribeData { - room_id, - project_id, - }) - } - - pub fn user_info(users: Vec) -> Self { - WsResponseData::UserInfo(users) - } - - pub fn reaction_list(data: room::MessageReactionsResponse) -> Self { - WsResponseData::ReactionList(ReactionListData::from(data)) - } - - pub fn reaction_list_batch(data: Vec) -> Self { - WsResponseData::ReactionListBatch(data.into_iter().map(ReactionListData::from).collect()) - } - - pub fn search_result(data: room::MessageSearchResponse) -> Self { - WsResponseData::SearchResult(SearchResultData::from(data)) - } - - pub fn edit_history(data: room::MessageEditHistoryResponse) -> Self { - WsResponseData::EditHistory(MessageEditHistoryResponse::from(data)) - } - - pub fn ai_list(configs: Vec) -> Self { - WsResponseData::AiList(configs.into_iter().map(AiConfigData::from).collect()) - } - - pub fn ai_config(config: room::RoomAiResponse) -> Self { - WsResponseData::AiConfig(Box::new(AiConfigData::from(config))) - } - - pub fn notification_list(data: room::NotificationListResponse) -> Self { - WsResponseData::NotificationList(NotificationListData::from(data)) - } - - pub fn mention_list(mentions: Vec) -> Self { - WsResponseData::MentionList(MentionListData::from(mentions)) - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct PinResponseData { - pub room: Uuid, - pub message: Uuid, - pub pinned_by: Uuid, - pub pinned_at: DateTime, - #[serde(skip_serializing_if = "Option::is_none")] - pub message_data: Option, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ReactionListData { - pub message_id: Uuid, - pub reactions: Vec, -} - -impl From for ReactionListData { - fn from(r: room::MessageReactionsResponse) -> Self { - Self { - message_id: r.message_id, - reactions: r - .reactions - .into_iter() - .map(|g| ReactionItem { - emoji: g.emoji, - count: g.count, - reacted_by_me: g.reacted_by_me, - users: g.users, - }) - .collect(), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ReactionItem { - pub emoji: String, - pub count: i32, - pub reacted_by_me: bool, - pub users: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SearchResultData { - pub messages: Vec, - pub total: i64, -} - -impl From for SearchResultData { - fn from(r: room::MessageSearchResponse) -> Self { - Self { - messages: r.messages, - total: r.total, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct MessageEditHistoryResponse { - pub message_id: Uuid, - pub history: Vec, - pub total_edits: i64, -} - -impl From for MessageEditHistoryResponse { - fn from(r: room::MessageEditHistoryResponse) -> Self { - Self { - message_id: r.message_id, - history: r - .history - .into_iter() - .map(|h| EditHistoryEntry { - old_content: h.old_content, - new_content: h.new_content, - edited_at: h.edited_at, - }) - .collect(), - total_edits: r.total_edits, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct EditHistoryEntry { - pub old_content: String, - pub new_content: String, - pub edited_at: chrono::DateTime, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AiConfigData { - pub room: Uuid, - pub model: Uuid, - pub version: Option, - pub call_count: i64, - pub last_call_at: Option>, - pub history_limit: Option, - pub system_prompt: Option, - pub temperature: Option, - pub max_tokens: Option, - pub use_exact: bool, - pub think: bool, - pub stream: bool, - pub min_score: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl From for AiConfigData { - fn from(r: room::RoomAiResponse) -> Self { - Self { - room: r.room, - model: r.model, - version: r.version, - call_count: r.call_count, - last_call_at: r.last_call_at, - history_limit: r.history_limit, - system_prompt: r.system_prompt, - temperature: r.temperature, - max_tokens: r.max_tokens, - use_exact: r.use_exact, - think: r.think, - stream: r.stream, - min_score: r.min_score, - created_at: r.created_at, - updated_at: r.updated_at, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct NotificationListData { - pub notifications: Vec, - pub total: i64, - pub unread_count: i64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct NotificationData { - pub id: Uuid, - pub room: Option, - pub project: Option, - pub user_id: Option, - pub user_info: Option, - pub notification_type: String, - pub title: String, - pub content: Option, - pub related_message_id: Option, - pub related_user_id: Option, - pub related_room_id: Option, - pub is_read: bool, - pub is_archived: bool, - pub created_at: DateTime, - pub read_at: Option>, - pub expires_at: Option>, -} - -impl From for NotificationListData { - fn from(r: room::NotificationListResponse) -> Self { - Self { - notifications: r - .notifications - .into_iter() - .map(|n| NotificationData { - id: n.id, - room: n.room, - project: n.project, - user_id: n.user_id, - user_info: n.user_info, - notification_type: n.notification_type, - title: n.title, - content: n.content, - related_message_id: n.related_message_id, - related_user_id: n.related_user_id, - related_room_id: n.related_room_id, - is_read: n.is_read, - is_archived: n.is_archived, - created_at: n.created_at, - read_at: n.read_at, - expires_at: n.expires_at, - }) - .collect(), - total: r.total, - unread_count: r.unread_count, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct MentionListData { - pub mentions: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MentionData { - pub message_id: Uuid, - pub mentioned_by: Uuid, - pub mentioned_by_name: String, - pub content_preview: String, - pub room_id: Uuid, - pub room_name: String, - pub created_at: DateTime, -} - -impl From> for MentionListData { - fn from(mentions: Vec) -> Self { - Self { - mentions: mentions - .into_iter() - .map(|m| MentionData { - message_id: m.message_id, - mentioned_by: m.mentioned_by, - mentioned_by_name: m.mentioned_by_name, - content_preview: m.content_preview, - room_id: m.room_id, - room_name: m.room_name, - created_at: m.created_at, - }) - .collect(), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SubscribeData { - pub room_id: Option, - pub project_id: Option, -} diff --git a/libs/api/room/ws_universal.rs b/libs/api/room/ws_universal.rs deleted file mode 100644 index b612dcd..0000000 --- a/libs/api/room/ws_universal.rs +++ /dev/null @@ -1,571 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use actix_web::{HttpRequest, HttpResponse, web}; -use actix_ws::Message as WsMessage; -use tokio_stream::StreamExt; -use tokio_stream::wrappers::BroadcastStream; -use uuid::Uuid; - -use crate::error::ApiError; -use queue::{ReactionGroup, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent}; -use room::types::NotificationEvent; -use room::connection::RoomConnectionManager; -use service::AppService; - -use super::ws::validate_origin; -use super::ws_handler::WsRequestHandler; -use super::ws_types::{WsAction, WsRequest, WsResponse, WsResponseData}; - -const MAX_TEXT_MESSAGE_LEN: usize = 64 * 1024; -const MAX_MESSAGES_PER_SECOND: u32 = 1000; -const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); -const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60); -const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300); -const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1); - -/// Unified push event from any subscribed room or user notification channel. -#[derive(Debug, Clone)] -pub enum WsPushEvent { - RoomMessage { - room_id: Uuid, - event: Arc, - }, - ReactionUpdated { - room_id: Uuid, - message_id: Uuid, - reactions: Vec, - }, - AiStreamChunk { - room_id: Uuid, - chunk: Arc, - }, - TypingIndicator { - room_id: Uuid, - event: Arc, - }, - Notification { - event: Arc, - }, -} - -/// Maps room_id -> (room_message_broadcast_stream, stream_chunk_broadcast_stream) -type PushStreams = HashMap< - Uuid, - ( - BroadcastStream>, - BroadcastStream>, - BroadcastStream>, - ), ->; - -pub async fn ws_universal( - service: web::Data, - req: HttpRequest, - stream: web::Payload, -) -> Result { - let origin_val = req - .headers() - .get("origin") - .and_then(|v| v.to_str().ok()) - .unwrap_or("(none)"); - if !validate_origin(&req) { - tracing::warn!( - origin = %origin_val, - "WS universal: origin rejected" - ); - return Err(ApiError(service::error::AppError::BadRequest( - "Invalid origin".into(), - )) - .into()); - } - - // Validate token BEFORE actix_ws::handle() so we can return a proper HTTP - // error if validation fails. Returning an HTTP error after handle() has been - // called (even if the handler returns an error) sends a 200 OK on what the - // browser expects to be a 101 Switching Protocols response — causing - // immediate close with readyState=3. - let user_id = if let Some(token) = req.uri().query().and_then(|q| { - q.split('&') - .find(|p| p.starts_with("token=")) - .and_then(|p| p.split('=').nth(1)) - }) { - tracing::debug!( - origin = %origin_val, - "WS universal: validating token" - ); - match service.ws_token.validate_token(token).await { - Ok(uid) => { - tracing::info!( - uid = %uid, - origin = %origin_val, - "WS universal: token auth successful" - ); - uid - } - Err(e) => { - tracing::warn!( - error = ?e, - origin = %origin_val, - "WS universal: token auth failed" - ); - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(ApiError(service::error::AppError::Unauthorized).into()); - } - } - } else { - let auth_header = req - .headers() - .get("Authorization") - .and_then(|v| v.to_str().ok()); - let token = match auth_header { - Some(h) if h.starts_with("Bearer ") => &h[7..], - _ => { - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(ApiError(service::error::AppError::Unauthorized).into()); - } - }; - - match extract_user_id_from_token(token) { - Some(id) => id, - None => { - service - .room - .room_manager - .metrics - .ws_auth_failures - .increment(1); - return Err(ApiError(service::error::AppError::Unauthorized).into()); - } - } - }; - - tracing::debug!( - user_id = %user_id, - origin = %origin_val, - "WS universal connection established" - ); - - let service = service.get_ref().clone(); - let manager = service.room.room_manager.clone(); - manager.metrics.ws_connections_active.increment(1.0); - manager.metrics.ws_connections_total.increment(1); - - // Subscribe to user-level notification stream immediately on connect - let notif_rx = manager.subscribe_user_notification(user_id).await; - let mut notif_stream = BroadcastStream::new(notif_rx); - - let (response, mut session, mut msg_stream) = actix_ws::handle(&req, stream)?; - actix::spawn(async move { - let handler = WsRequestHandler::new(Arc::new(service), user_id); - let mut push_streams: PushStreams = HashMap::new(); - let mut shutdown_rx = manager.subscribe_shutdown(); - let mut last_heartbeat = Instant::now(); - let mut last_activity = Instant::now(); - let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL); - heartbeat_interval.tick().await; - let mut message_count: u32 = 0; - let mut rate_window_start = Instant::now(); - loop { - tokio::select! { - _ = heartbeat_interval.tick() => { - if last_heartbeat.elapsed() > HEARTBEAT_TIMEOUT { - tracing::warn!(user_id = %user_id, "WS universal heartbeat timeout"); - manager.metrics.ws_heartbeat_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Policy.into())).await; - break; - } - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(user_id = %user_id, "WS universal idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - if session.ping(b"").await.is_err() { - break; - } - manager.metrics.ws_heartbeat_sent_total.increment(1); - } - _ = shutdown_rx.recv() => { - tracing::info!("WS universal shutdown"); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - notif_result = notif_stream.next() => { - match notif_result { - Some(Ok(event)) => { - let payload = serde_json::json!({ - "type": "event", - "event": "notification_created", - "data": { - "event_type": event.event_type, - "notification": event.notification, - "deep_link_url": event.deep_link_url, - "timestamp": event.timestamp, - }, - }); - if session.text(payload.to_string()).await.is_err() { - break; - } - } - Some(Err(_)) | None => { - // Notification channel lagged or closed — re-subscribe - let rx = manager.subscribe_user_notification(user_id).await; - notif_stream = BroadcastStream::new(rx); - } - } - } - push_event = poll_push_streams(&mut push_streams, &manager, &handler.service(), user_id) => { - match push_event { - Some(WsPushEvent::RoomMessage { room_id, event }) => { - let payload = serde_json::json!({ - "type": "event", - "event": "room.message", - "room_id": room_id, - "data": { - "id": event.id, - "room_id": event.room_id, - "sender_type": event.sender_type, - "sender_id": event.sender_id, - "thread_id": event.thread_id, - "content": event.content, - "content_type": event.content_type, - "send_at": event.send_at, - "seq": event.seq, - "display_name": event.display_name, - }, - }); - if session.text(payload.to_string()).await.is_err() { - break; - } - } - Some(WsPushEvent::ReactionUpdated { room_id, message_id, reactions }) => { - let payload = serde_json::json!({ - "type": "event", - "event": "room.reaction_updated", - "room_id": room_id, - "data": { - "message_id": message_id, - "reactions": reactions, - }, - }); - if session.text(payload.to_string()).await.is_err() { - break; - } - } - Some(WsPushEvent::AiStreamChunk { room_id, chunk }) => { - let payload = serde_json::json!({ - "type": "event", - "event": "ai.stream_chunk", - "room_id": room_id, - "data": { - "message_id": chunk.message_id, - "room_id": chunk.room_id, - "seq": chunk.seq, - "content": chunk.content, - "done": chunk.done, - "error": chunk.error, - "display_name": chunk.display_name, - "chunk_type": chunk.chunk_type, - }, - }); - if session.text(payload.to_string()).await.is_err() { - break; - } - } - Some(WsPushEvent::TypingIndicator { room_id, event }) => { - let payload = serde_json::json!({ - "type": "event", - "event": "room.typing", - "room_id": room_id, - "data": { - "user_id": event.user_id, - "username": event.username, - "avatar_url": event.avatar_url, - "action": event.action, - "sender_type": event.sender_type.as_deref().unwrap_or("user"), - }, - }); - if session.text(payload.to_string()).await.is_err() { - break; - } - } - None => { - } - Some(WsPushEvent::Notification { .. }) => { - // Notification events are handled via the notif_stream branch above - } - } - } - msg = msg_stream.recv() => { - match msg { - Some(Ok(WsMessage::Ping(bytes))) => { - if session.pong(&bytes).await.is_err() { break; } - last_heartbeat = Instant::now(); - } - Some(Ok(WsMessage::Pong(_))) => { last_heartbeat = Instant::now(); } - Some(Ok(WsMessage::Text(text))) => { - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(user_id = %user_id, "WS universal idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); - let _ = session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - last_activity = Instant::now(); - - if rate_window_start.elapsed() > RATE_LIMIT_WINDOW { - message_count = 0; - rate_window_start = Instant::now(); - } - message_count += 1; - if message_count > MAX_MESSAGES_PER_SECOND { - tracing::warn!(user_id = %user_id, "WS universal rate limit exceeded"); - manager.metrics.ws_rate_limit_hits.increment(1); - let _ = session.text(serde_json::json!({"type":"error","error":"rate_limit_exceeded"}).to_string()).await; - continue; - } - - if text.len() > MAX_TEXT_MESSAGE_LEN { - tracing::warn!(user_id = %user_id, bytes = text.len(), "WS universal message too long"); - let _ = session.text(serde_json::json!({"type":"error","error":"message_too_long"}).to_string()).await; - continue; - } - - // Handle JSON-level ping (application heartbeat). - // Client sends {"type":"ping"} and we reply with {"type":"pong"}. - if text.trim() == r#"{"type":"ping"}"# { - if session.text(r#"{"type":"pong"}"#).await.is_err() { break; } - last_activity = Instant::now(); - last_heartbeat = Instant::now(); - continue; - } - - match serde_json::from_str::(&text) { - Ok(request) => { - let action_str = request.action.to_string(); - match request.action { - WsAction::SubscribeRoom => { - if let Some(room_id) = request.params().room_id { - // Verify user has access to this room before subscribing - if let Err(e) = handler.service().room.check_room_access(room_id, user_id).await { - let _ = session.text(serde_json::to_string(&WsResponse::error_response( - request.request_id, &action_str, 403, "access_denied", &format!("{}", e) - )).unwrap_or_default()).await; - } else { - handler.service().room.spawn_room_workers(room_id); - match manager.subscribe(room_id, user_id).await { - Ok(rx) => { - let stream_rx = manager.subscribe_room_stream(room_id).await; - let typing_rx = manager.subscribe_typing(room_id).await; - push_streams.insert(room_id, ( - BroadcastStream::new(rx), - BroadcastStream::new(stream_rx), - BroadcastStream::new(typing_rx), - )); - let _ = session.text(serde_json::to_string(&WsResponse::success( - request.request_id, &action_str, - WsResponseData::subscribed(Some(room_id), None) - )).unwrap_or_default()).await; - } - Err(e) => { - let _ = session.text(serde_json::to_string(&WsResponse::error_response( - request.request_id, &action_str, 500, "subscribe_failed", &format!("{}", e) - )).unwrap_or_default()).await; - } - } - } - } else { - let _ = session.text(serde_json::to_string(&WsResponse::error_response( - request.request_id, &action_str, 400, "bad_request", "room_id required" - )).unwrap_or_default()).await; - } - } - WsAction::UnsubscribeRoom => { - if let Some(room_id) = request.params().room_id { - manager.unsubscribe(room_id, user_id).await; - push_streams.remove(&room_id); - } - let _ = session.text(serde_json::to_string(&WsResponse::success( - request.request_id, &action_str, WsResponseData::bool(true) - )).unwrap_or_default()).await; - } - WsAction::TypingStart | WsAction::TypingStop => { - if let (Some(room_id), Some(action)) = - (request.params().room_id, request.params().typing.as_deref()) - { - let names = handler.service().room.get_user_names(&[user_id]).await; - let typing_event = TypingEvent { - room_id, - user_id, - username: names.into_values().next().unwrap_or_else(|| "unknown".to_string()), - avatar_url: None, - action: action.to_string(), - sender_type: None, - }; - manager.broadcast_typing(room_id, typing_event).await; - } - let _ = session.text(serde_json::to_string(&WsResponse::success( - request.request_id, &action_str, WsResponseData::bool(true) - )).unwrap_or_default()).await; - } - _ => { - let resp = handler.handle(request).await; - let _ = session.text(serde_json::to_string(&resp).unwrap_or_default()).await; - } - } - } - Err(e) => { - tracing::warn!(user_id = %user_id, error = %e, "WS universal parse error"); - let _ = session.text(serde_json::json!({"type":"error","error":"parse_error"}).to_string()).await; - } - } - } - Some(Ok(WsMessage::Binary(_))) => { break; } - Some(Ok(WsMessage::Continuation(_))) => {} - Some(Ok(WsMessage::Nop)) => {} - Some(Ok(WsMessage::Close(reason))) => { let _ = session.close(reason).await; break; } - Some(Err(e)) => { tracing::warn!(error = %e, "WS error"); break; } - None => break, - } - } - } - } - - // Clean up subscriptions on disconnect - for room_id in push_streams.keys() { - manager.unsubscribe(*room_id, user_id).await; - } - manager.unsubscribe_user_notification(user_id).await; - manager.metrics.ws_connections_active.decrement(1.0); - manager.metrics.ws_disconnections_total.increment(1); - }); - - Ok(response) -} - -async fn poll_push_streams( - streams: &mut PushStreams, - manager: &Arc, - service: &Arc, - user_id: Uuid, -) -> Option { - loop { - let room_ids: Vec = streams.keys().copied().collect(); - let mut dead_rooms: Vec = Vec::new(); - - for room_id in room_ids { - if let Some((msg_stream, chunk_stream, typing_stream)) = streams.get_mut(&room_id) { - tokio::select! { - result = msg_stream.next() => { - match result { - Some(Ok(event)) => { - if let Some(reactions) = event.reactions.clone() { - return Some(WsPushEvent::ReactionUpdated { - room_id: event.room_id, - message_id: event.message_id.unwrap_or(event.id), - reactions, - }); - } - return Some(WsPushEvent::RoomMessage { room_id, event }); - } - Some(Err(_)) | None => { - dead_rooms.push(room_id); - } - } - } - result = chunk_stream.next() => { - match result { - Some(Ok(chunk)) => { - return Some(WsPushEvent::AiStreamChunk { room_id, chunk }); - } - Some(Err(_)) | None => { - dead_rooms.push(room_id); - } - } - } - result = typing_stream.next() => { - match result { - Some(Ok(event)) => { - return Some(WsPushEvent::TypingIndicator { room_id, event }); - } - Some(Err(_)) | None => { - // Typing channel going dead is non-fatal — typing is ephemeral - } - } - } - } - } - } - - // Re-subscribe dead rooms so we don't permanently lose events. - // Re-check access in case the user's permissions were revoked while the - // stream was dead. - for room_id in dead_rooms { - if streams.remove(&room_id).is_some() { - if service.room.check_room_access(room_id, user_id).await.is_ok() { - if let Ok(rx) = manager.subscribe(room_id, user_id).await { - let stream_rx = manager.subscribe_room_stream(room_id).await; - let typing_rx = manager.subscribe_typing(room_id).await; - streams.insert(room_id, ( - BroadcastStream::new(rx), - BroadcastStream::new(stream_rx), - BroadcastStream::new(typing_rx), - )); - } - } - // If access check fails, silently skip re-subscribe (user was removed) - } - } - - if streams.is_empty() { - // Yield so the caller can drop us before the next iteration - tokio::time::sleep(Duration::from_millis(50)).await; - } else { - tokio::task::yield_now().await; - } - } -} - -fn extract_user_id_from_token(token: &str) -> Option { - if token.len() < 64 { - return None; - } - let token_data = base64_decode(token)?; - if token_data.len() < 16 { - return None; - } - let bytes: [u8; 16] = token_data[..16].try_into().ok()?; - Some(Uuid::from_bytes(bytes)) -} - -fn base64_decode(input: &str) -> Option> { - let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut result = Vec::with_capacity(input.len() * 3 / 4); - let mut buffer: u32 = 0; - let mut bits = 0; - - for byte in input.bytes() { - if byte == b'=' || byte == b'\n' || byte == b'\r' || byte == b' ' { - continue; - } - let idx = table.iter().position(|&x| x == byte)?; - buffer = (buffer << 6) | (idx as u32); - bits += 6; - if bits >= 8 { - bits -= 8; - result.push((buffer >> bits) as u8); - } - } - Some(result) -} diff --git a/libs/api/route.rs b/libs/api/route.rs index a254016..4b23586 100644 --- a/libs/api/route.rs +++ b/libs/api/route.rs @@ -15,16 +15,16 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) { .configure(crate::auth::init_auth_routes) .configure(crate::git::init_git_routes) .configure(crate::git::init_git_toplevel_routes) + .configure(crate::chat::init_chat_routes) .configure(crate::issue::init_issue_routes) .configure(crate::project::init_project_routes) .configure(crate::user::init_user_routes) .configure(crate::pull_request::init_pull_request_routes) .configure(crate::agent::init_agent_routes) - .configure(crate::workspace::init_workspace_routes) .configure(crate::search::init_search_routes) .configure(crate::room::init_room_routes), ); // SPA fallback — must be registered last so /api/* takes precedence - // cfg.route("/{path:.*}", web::get().to(crate::dist::serve_frontend)); + cfg.route("/{path:.*}", web::get().to(crate::dist::serve_frontend)); } diff --git a/libs/api/user/billing.rs b/libs/api/user/billing.rs new file mode 100644 index 0000000..76be0ac --- /dev/null +++ b/libs/api/user/billing.rs @@ -0,0 +1,57 @@ +use crate::{ApiResponse, error::ApiError}; +use actix_web::{HttpResponse, Result, web}; +use service::AppService; +use session::Session; + +#[utoipa::path( + get, + path = "/api/users/me/billing", + responses( + (status = 200, description = "Get user billing", body = ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "User" +)] +pub async fn user_billing( + service: web::Data, + session: Session, +) -> Result { + let resp = service.user_billing_current(&session).await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/users/me/billing/errors", + responses( + (status = 200, description = "Get user billing errors", body = ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "User" +)] +pub async fn user_billing_errors( + service: web::Data, + session: Session, +) -> Result { + let resp = service.user_billing_errors(&session).await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +#[utoipa::path( + get, + path = "/api/users/me/billing/history", + params(("page" = Option, Query), ("per_page" = Option, Query)), + responses( + (status = 200, description = "Get user billing history", body = ApiResponse), + (status = 401, description = "Unauthorized"), + ), + tag = "User" +)] +pub async fn user_billing_history( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let resp = service.user_billing_history(&session, query.into_inner()).await?; + Ok(ApiResponse::ok(resp).to_response()) +} \ No newline at end of file diff --git a/libs/api/user/mod.rs b/libs/api/user/mod.rs index 6ff2a40..07ed691 100644 --- a/libs/api/user/mod.rs +++ b/libs/api/user/mod.rs @@ -1,5 +1,6 @@ pub mod access_key; pub mod avatar; +pub mod billing; pub mod chpc; pub mod notification; pub mod preferences; @@ -11,6 +12,7 @@ pub mod stars; pub mod subscribe; pub mod user_activity; pub mod user_info; +pub mod summary; use actix_web::web; @@ -71,6 +73,18 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) { "/me/heatmap", web::get().to(chpc::get_my_contribution_heatmap), ) + .route( + "/me/billing", + web::get().to(billing::user_billing), + ) + .route( + "/me/billing/errors", + web::get().to(billing::user_billing_errors), + ) + .route( + "/me/billing/history", + web::get().to(billing::user_billing_history), + ) .route( "/me/projects", web::get().to(projects::get_current_user_projects), @@ -85,11 +99,12 @@ pub fn init_user_routes(cfg: &mut web::ServiceConfig) { web::get().to(profile::get_profile_by_username), ) .route("/{username}/info", web::get().to(user_info::get_user_info)) + .route("/{username}/summary", web::get().to(summary::get_user_summary)) .route( "/{username}/heatmap", web::get().to(chpc::get_contribution_heatmap), ) - .route("/{username}/keys", web::get().to(ssh_key::list_ssh_keys)) + .route("/{username}/keys", web::get().to(ssh_key::list_user_ssh_keys)) .route("/{username}/activity", web::get().to(user_activity::get_user_activity)) .route("/{username}/stars", web::get().to(stars::get_user_stars)) .route( diff --git a/libs/api/user/ssh_key.rs b/libs/api/user/ssh_key.rs index fe8a20b..59931cc 100644 --- a/libs/api/user/ssh_key.rs +++ b/libs/api/user/ssh_key.rs @@ -108,3 +108,26 @@ pub async fn delete_ssh_key( .await?; Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) } + +#[utoipa::path( + get, + path = "/api/users/{username}/keys", + params( + ("username" = String, Path, description = "Username"), + ), + responses( + (status = 200, description = "List user's SSH keys", body = ApiResponse), + (status = 404, description = "User not found"), + ), + tag = "User" +)] +pub async fn list_user_ssh_keys( + service: web::Data, + path: web::Path, +) -> Result { + let username = path.into_inner(); + let resp = service + .user_list_ssh_keys_by_username(username) + .await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/user/summary.rs b/libs/api/user/summary.rs new file mode 100644 index 0000000..e1336fc --- /dev/null +++ b/libs/api/user/summary.rs @@ -0,0 +1,25 @@ +use crate::{ApiResponse, error::ApiError}; +use actix_web::{HttpResponse, Result, web}; +use service::AppService; +use session::Session; + +#[utoipa::path( + get, + path = "/api/users/{username}/summary", + params(("username" = String, Path)), + responses( + (status = 200, description = "Get user summary for dashboard", body = ApiResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Not found"), + ), + tag = "User" +)] +pub async fn get_user_summary( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let username = path.into_inner(); + let resp = service.user_get_summary(session, username).await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/config/database.rs b/libs/config/database.rs index bc31059..b63801e 100644 --- a/libs/config/database.rs +++ b/libs/config/database.rs @@ -23,19 +23,19 @@ impl AppConfig { if let Some(idle_timeout) = self.env.get("APP_DATABASE_IDLE_TIMEOUT") { return Ok(idle_timeout.parse::()?); } - Ok(60000) + Ok(60000) // milliseconds } pub fn database_max_lifetime(&self) -> anyhow::Result { if let Some(max_lifetime) = self.env.get("APP_DATABASE_MAX_LIFETIME") { return Ok(max_lifetime.parse::()?); } - Ok(300000) + Ok(300000) // milliseconds } pub fn database_connection_timeout(&self) -> anyhow::Result { if let Some(connection_timeout) = self.env.get("APP_DATABASE_CONNECTION_TIMEOUT") { return Ok(connection_timeout.parse::()?); } - Ok(5000) + Ok(5000) // milliseconds } pub fn database_schema_search_path(&self) -> anyhow::Result { if let Some(schema_search_path) = self.env.get("APP_DATABASE_SCHEMA_SEARCH_PATH") { diff --git a/libs/db/Cargo.toml b/libs/db/Cargo.toml index a91ce80..51d4834 100644 --- a/libs/db/Cargo.toml +++ b/libs/db/Cargo.toml @@ -21,5 +21,9 @@ config = { workspace = true } anyhow = { workspace = true } tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } async-trait = { workspace = true } +serde_json = { workspace = true } +redis = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } [lints] workspace = true diff --git a/libs/db/cache.rs b/libs/db/cache.rs index 9891ebd..7d69a56 100644 --- a/libs/db/cache.rs +++ b/libs/db/cache.rs @@ -1,5 +1,9 @@ use config::AppConfig; use deadpool_redis::cluster::{Connection, Manager, Pool}; +use uuid::Uuid; + +const CHAT_STREAM_KEY_PREFIX: &str = "chat:stream:"; +const CHAT_STREAM_TTL_SECS: u64 = 600; // 10 minutes #[derive(Clone)] pub struct AppCache { @@ -33,4 +37,60 @@ impl AppCache { pub fn redis_url(&self) -> &str { &self.redis_url } + + /// Set chat stream active state (conversation_id → {message_id, started_at}). + /// TTL 10 minutes — prevents stale entries. + pub async fn set_chat_stream_active(&self, conversation_id: Uuid, message_id: Uuid) -> bool { + if let Ok(mut conn) = self.conn().await { + let key = format!("{}{}", CHAT_STREAM_KEY_PREFIX, conversation_id); + let value = serde_json::json!({ + "message_id": message_id.to_string(), + "started_at": chrono::Utc::now().timestamp(), + }) + .to_string(); + let _: Result<(), _> = redis::cmd("SETEX") + .arg(&key) + .arg(CHAT_STREAM_TTL_SECS as i64) + .arg(&value) + .query_async(&mut conn) + .await; + return true; + } + false + } + + /// Get active chat stream state for a conversation. Returns (message_id, started_at_ts). + pub async fn get_chat_stream_active( + &self, + conversation_id: Uuid, + ) -> Option<(Uuid, i64)> { + if let Ok(mut conn) = self.conn().await { + let key = format!("{}{}", CHAT_STREAM_KEY_PREFIX, conversation_id); + if let Ok(value) = + redis::cmd("GET").arg(&key).query_async::(&mut conn).await + { + if let Ok(parsed) = serde_json::from_str::(&value) { + let msg_id = parsed + .get("message_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()); + let started_at = parsed + .get("started_at") + .and_then(|v| v.as_i64()); + if let (Some(mid), Some(ts)) = (msg_id, started_at) { + return Some((mid, ts)); + } + } + } + } + None + } + + /// Clear chat stream active state (called when streaming finishes). + pub async fn clear_chat_stream_active(&self, conversation_id: Uuid) { + if let Ok(mut conn) = self.conn().await { + let key = format!("{}{}", CHAT_STREAM_KEY_PREFIX, conversation_id); + let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await; + } + } } diff --git a/libs/db/database.rs b/libs/db/database.rs index b965878..fc0a379 100644 --- a/libs/db/database.rs +++ b/libs/db/database.rs @@ -26,9 +26,9 @@ impl AppDatabase { let conn_cfg = sea_orm::ConnectOptions::new(db_url) .max_connections(max_connections) .min_connections(min_connections) - .idle_timeout(Duration::from_secs(idle_timeout)) - .max_lifetime(Duration::from_secs(max_lifetime)) - .connect_timeout(Duration::from_secs(connection_timeout)) + .idle_timeout(Duration::from_millis(idle_timeout)) + .max_lifetime(Duration::from_millis(max_lifetime)) + .connect_timeout(Duration::from_millis(connection_timeout)) .set_schema_search_path(schema_search_path) .sqlx_logging(false) .to_owned(); @@ -39,11 +39,10 @@ impl AppDatabase { let conn_cfg = sea_orm::ConnectOptions::new(replica_url.clone()) .max_connections(max_connections) .min_connections(min_connections) - .idle_timeout(Duration::from_secs(idle_timeout)) - .max_lifetime(Duration::from_secs(max_lifetime)) - .connect_timeout(Duration::from_secs(connection_timeout)) + .idle_timeout(Duration::from_millis(idle_timeout)) + .max_lifetime(Duration::from_millis(max_lifetime)) + .connect_timeout(Duration::from_millis(connection_timeout)) .to_owned(); - Some(Database::connect(conn_cfg).await?) } else { None diff --git a/libs/fctool/Cargo.toml b/libs/fctool/Cargo.toml index 0010c5c..a88254b 100644 --- a/libs/fctool/Cargo.toml +++ b/libs/fctool/Cargo.toml @@ -21,9 +21,12 @@ agent = { workspace = true } git = { workspace = true } models = { workspace = true } db = { workspace = true } +queue = { workspace = true } sea-orm = { workspace = true, features = [] } git2 = { workspace = true } +ammonia = "4.0" +redis = { workspace = true, features = ["tokio-comp"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } base64 = { workspace = true } diff --git a/libs/fctool/src/chat_tools/mod.rs b/libs/fctool/src/chat_tools/mod.rs new file mode 100644 index 0000000..d252d96 --- /dev/null +++ b/libs/fctool/src/chat_tools/mod.rs @@ -0,0 +1,42 @@ +//! Chat tools for AI agent function calling. +//! +//! Tools for managing AI conversations: title generation, +//! sending messages, retracting messages. +//! +//! `register_all` registers globally-available tools (e.g. title generation). +//! `register_room_tools` registers room-only tools (send_message, retract_message) +//! that should only be available when the AI is @mentioned in a room. + +mod retract_message; +mod send_message; +mod title; + +use agent::{ToolHandler, ToolRegistry}; + +pub use retract_message::retract_message_exec; +pub use send_message::send_message_exec; +pub use title::generate_title_exec; + +/// Register globally-available chat tools (title generation only). +/// Room-specific tools (send_message, retract_message) are registered +/// separately via `register_room_tools` when the AI is @mentioned in a room. +pub fn register_all(registry: &mut ToolRegistry) { + registry.register( + title::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(generate_title_exec(ctx, args))), + ); +} + +/// Register room-only tools. These should only be available when the AI is +/// @mentioned in a room, allowing the AI to send short messages and retract +/// its own messages instead of producing long-form output. +pub fn register_room_tools(registry: &mut ToolRegistry) { + registry.register( + send_message::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(send_message_exec(ctx, args))), + ); + registry.register( + retract_message::tool_definition(), + ToolHandler::new(|ctx, args| Box::pin(retract_message_exec(ctx, args))), + ); +} diff --git a/libs/fctool/src/chat_tools/retract_message.rs b/libs/fctool/src/chat_tools/retract_message.rs new file mode 100644 index 0000000..088d602 --- /dev/null +++ b/libs/fctool/src/chat_tools/retract_message.rs @@ -0,0 +1,155 @@ +//! `retract_message` tool: revokes a message previously sent by the AI agent. +//! +//! Only messages sent in the current turn (matching sender_id) can be retracted. + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use models::projects::project_members; +use models::rooms::room_message; +use queue::ProjectRoomEvent; +use sea_orm::*; +use std::collections::HashMap; +use uuid::Uuid; + +/// Retract (revoke) a message that was sent by the current agent in the current +/// conversation turn. Cannot retract messages sent by other users or in previous +/// turns. +pub async fn retract_message_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let message_id = args + .get("message_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .ok_or_else(|| { + ToolError::ExecutionError("message_id is required and must be a valid UUID".into()) + })?; + + let db = ctx.db(); + let sender_id = ctx.sender_id(); + + // In room context, the AI model is the sender — use its ID for authorization. + let effective_sender_id = ctx.ai_model_id().or(sender_id); + + let model = room_message::Entity::find_by_id(message_id) + .one(db.reader()) + .await + .map_err(|e| ToolError::ExecutionError(format!("db error: {}", e)))? + .ok_or_else(|| ToolError::ExecutionError(format!("message not found: {}", message_id)))?; + + let room_id = model.room; + let project_id = ctx.project_id(); + + // Allow retraction if sender is the original author OR a room admin + let is_author = model.sender_id == effective_sender_id; + let is_admin = match effective_sender_id { + Some(uid) => is_room_admin(db, room_id, uid, project_id).await, + None => false, + }; + if !is_author && !is_admin { + return Err(ToolError::ExecutionError( + "can only retract your own messages or be a room admin".into(), + )); + } + + // Must not already be revoked + if model.revoked.is_some() { + return Err(ToolError::ExecutionError( + "message is already revoked".into(), + )); + } + + // Must be a message sent in the current turn — cannot retract across turns + if !ctx.is_sent_in_turn(message_id) { + return Err(ToolError::ExecutionError( + "can only retract messages sent in the current turn — \ + cross-turn retraction is not allowed".into(), + )); + } + + let now = Utc::now(); + let mut active: room_message::ActiveModel = model.into(); + active.revoked = Set(Some(now)); + active.revoked_by = Set(effective_sender_id); + active + .update(db.writer()) + .await + .map_err(|e| ToolError::ExecutionError(format!("failed to revoke message: {}", e)))?; + + // Publish retraction event for real-time delivery + if let Some(producer) = ctx.message_producer() { + let event = ProjectRoomEvent { + event_type: "message_revoked".to_string(), + project_id, + room_id: Some(room_id), + category_id: None, + message_id: Some(message_id), + seq: None, + timestamp: now, + }; + producer.publish_project_room_event(project_id, event).await; + } + + Ok(serde_json::json!({ + "message_id": message_id.to_string(), + "revoked": true, + "revoked_at": now.to_rfc3339(), + })) +} + +async fn is_room_admin( + db: &db::database::AppDatabase, + room_id: Uuid, + user_id: Uuid, + project_id: Uuid, +) -> bool { + use models::rooms::room; + let room_model = room::Entity::find_by_id(room_id) + .one(db.reader()).await.ok().flatten(); + match room_model { + Some(r) if r.created_by == user_id => true, + Some(_) => { + let member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(user_id)) + .one(db.reader()).await.ok().flatten(); + match member { + Some(m) => matches!(m.scope_role(), Ok(models::projects::MemberRole::Admin | models::projects::MemberRole::Owner)), + None => false, + } + } + None => false, + } +} + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert( + "message_id".into(), + ToolParam { + name: "message_id".into(), + param_type: "string".into(), + description: Some( + "The UUID of the message to retract. Must be a message sent by you \ + in the current conversation turn." + .into(), + ), + required: true, + properties: None, + items: None, + }, + ); + ToolDefinition::new("retract_message") + .description( + "Retract (revoke) a message you previously sent in the current turn. \ + Only works for messages sent by the AI agent — cannot retract user \ + messages or messages from previous turns. Use this to clean up mistakes \ + or remove messages that are no longer relevant.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["message_id".into()]), + }) +} diff --git a/libs/fctool/src/chat_tools/send_message.rs b/libs/fctool/src/chat_tools/send_message.rs new file mode 100644 index 0000000..bf12ed0 --- /dev/null +++ b/libs/fctool/src/chat_tools/send_message.rs @@ -0,0 +1,229 @@ +//! `send_message` tool: sends a brief message to a specified room. +//! +//! Supports mentions in the standard `@[type:id:label]` format: +//! - `@[user:uuid:username]` — mention a user +//! - `@[repo:uuid:name]` — mention a repository +//! - `@[skill:slug]` — mention a skill +//! - `@[ai:uuid:name]` — mention an AI model +//! - `@[issue:uuid:title]` — mention an issue + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use models::rooms::{ + room_message, MessageContentType, MessageSenderType, +}; +use queue::{ProjectRoomEvent, RoomMessageEnvelope}; +use sea_orm::*; +use std::collections::HashMap; +use uuid::Uuid; + +const MAX_CONTENT_LEN: usize = 10000; + +/// Send a brief message to the specified room. +/// The message content may include mentions in `@[type:id:label]` format. +pub async fn send_message_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let room_id = args + .get("room_id") + .and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(|| ctx.room_id()); + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::ExecutionError("content is required".into()))?; + + if content.trim().is_empty() { + return Err(ToolError::ExecutionError("content cannot be empty".into())); + } + if content.len() > MAX_CONTENT_LEN { + return Err(ToolError::ExecutionError(format!( + "content too long ({} chars, max {})", + content.len(), + MAX_CONTENT_LEN + ))); + } + + let content = ammonia::clean(content); + + let db = ctx.db(); + let cache = ctx.cache(); + let sender_id = ctx.sender_id(); + + // In room context, the AI model is the sender — not the user who @mentioned it. + let (effective_sender_id, display_name) = match ctx.ai_model_id() { + Some(model_id) => (Some(model_id), ctx.ai_model_name()), + None => (sender_id, None), + }; + + // Verify room exists + let room_model = models::rooms::room::Entity::find_by_id(room_id) + .one(db.reader()) + .await + .map_err(|e| ToolError::ExecutionError(format!("db error: {}", e)))? + .ok_or_else(|| ToolError::ExecutionError(format!("room not found: {}", room_id)))?; + + // Sequence: use Redis atomic INCR if available, fall back to DB MAX+1 + let seq = get_next_seq(room_id, db, cache) + .await + .map_err(|e| ToolError::ExecutionError(format!("seq error: {}", e)))?; + + let now = Utc::now(); + let message_id = Uuid::now_v7(); + + room_message::Entity::insert(room_message::ActiveModel { + id: Set(message_id), + seq: Set(seq), + room: Set(room_id), + sender_type: Set(MessageSenderType::Ai), + sender_id: Set(effective_sender_id), + model_id: Set(ctx.ai_model_id()), + thread: Set(None), + content: Set(content.clone()), + content_type: Set(MessageContentType::Text), + thinking_content: Set(None), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(db.writer()) + .await + .map_err(|e| ToolError::ExecutionError(format!("failed to insert message: {}", e)))?; + + // Register this message in the current turn so retract_message can validate it + ctx.register_sent_message(message_id); + + // Update room last_msg_at + let mut room_active: models::rooms::room::ActiveModel = room_model.into(); + room_active.last_msg_at = Set(now); + room_active + .update(db.writer()) + .await + .map_err(|e| ToolError::ExecutionError(format!("failed to update room: {}", e)))?; + + // Publish via message queue for real-time delivery to other clients + if let Some(producer) = ctx.message_producer() { + let envelope = RoomMessageEnvelope { + id: message_id, + dedup_key: Some(format!("{}:{}", room_id, message_id)), + room_id, + sender_type: "ai".to_string(), + sender_id: effective_sender_id, + model_id: ctx.ai_model_id(), + thread_id: None, + in_reply_to: None, + content: content.clone(), + content_type: "text".to_string(), + thinking_content: None, + send_at: now, + seq, + display_name, + }; + let _ = producer.publish(room_id, envelope).await; + + let project_event = ProjectRoomEvent { + event_type: "new_message".to_string(), + project_id: ctx.project_id(), + room_id: Some(room_id), + category_id: None, + message_id: Some(message_id), + seq: Some(seq), + timestamp: now, + }; + producer.publish_project_room_event(ctx.project_id(), project_event).await; + } + + Ok(serde_json::json!({ + "message_id": message_id.to_string(), + "room_id": room_id.to_string(), + "seq": seq, + "content": content, + "sent_at": now.to_rfc3339(), + })) +} + +async fn get_next_seq( + room_id: Uuid, + db: &db::database::AppDatabase, + cache: &db::cache::AppCache, +) -> Result { + // Try Redis atomic INCR first + let seq_key = format!("room:seq:{}", room_id); + if let Ok(mut conn) = cache.conn().await { + let result: Result, _> = redis::cmd("INCR") + .arg(&seq_key) + .query_async(&mut conn) + .await; + if let Ok(Some(seq)) = result { + return Ok(seq); + } + } + + // Fallback: DB MAX+1 + let max_seq = room_message::Entity::find() + .filter(room_message::Column::Room.eq(room_id)) + .select_only() + .column_as(room_message::Column::Seq.max(), "max_seq") + .into_tuple::>>() + .one(db.reader()) + .await + .map_err(|e| ToolError::ExecutionError(format!("db max seq: {}", e)))? + .flatten() + .flatten() + .unwrap_or(0); + Ok(max_seq + 1) +} + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert( + "content".into(), + ToolParam { + name: "content".into(), + param_type: "string".into(), + description: Some( + "Brief message content. Keep it concise — no long reports. \ + Use @[type:id:label] syntax to mention users, repos, skills, issues, etc. \ + Example: '@[user:550e8400-e29b-41d4-a716-446655440000:alice] see the repo @[repo:660e8400-e29b-41d4-a716-446655440001:backend] for details.'" + .into(), + ), + required: true, + properties: None, + items: None, + }, + ); + p.insert( + "room_id".into(), + ToolParam { + name: "room_id".into(), + param_type: "string".into(), + description: Some( + "The UUID of the room to send the message to. \ + If omitted, defaults to the current room context." + .into(), + ), + required: false, + properties: None, + items: None, + }, + ); + ToolDefinition::new("send_message") + .description( + "Send a brief message to a room. Keep content concise and to the point. \ + Use @[type:id:label] syntax to mention project resources: \ + @[user:uuid:name] for users, @[repo:uuid:name] for repos, \ + @[skill:slug] for skills, @[issue:uuid:title] for issues, etc. \ + Do NOT use this for long reports or multi-paragraph explanations — \ + use it only for short notifications, status updates, or asking follow-up questions.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["content".into()]), + }) +} diff --git a/libs/fctool/src/chat_tools/title.rs b/libs/fctool/src/chat_tools/title.rs new file mode 100644 index 0000000..336d4e2 --- /dev/null +++ b/libs/fctool/src/chat_tools/title.rs @@ -0,0 +1,151 @@ +//! `generate_title` tool: reads conversation history, generates a short title, saves it to the conversation. + +use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema}; +use chrono::Utc; +use models::ai::{ai_conversation, ai_message, AiMessage}; +use sea_orm::*; +use std::collections::HashMap; + +/// Generate a concise title for the current conversation based on its message history. +/// The title must be 5 words or fewer. Updates the conversation record. +pub async fn generate_title_exec( + ctx: ToolContext, + args: serde_json::Value, +) -> Result { + let conversation_id = args + .get("conversation_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + .ok_or_else(|| { + ToolError::ExecutionError("conversation_id is required and must be a valid UUID".into()) + })?; + + let db = ctx.db(); + + // Load conversation + let conv = ai_conversation::Entity::find_by_id(conversation_id) + .one(db.reader()) + .await + .map_err(|e| ToolError::ExecutionError(format!("db error: {}", e)))? + .ok_or_else(|| ToolError::ExecutionError("Conversation not found".into()))?; + + // Load recent user messages (last 3) as context + let recent_messages = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::Role.eq("user")) + .order_by_desc(ai_message::Column::CreatedAt) + .limit(3) + .all(db.reader()) + .await + .map_err(|e| ToolError::ExecutionError(format!("db error: {}", e)))?; + + if recent_messages.is_empty() { + return Err(ToolError::ExecutionError( + "No user messages found in conversation".into(), + )); + } + + // Build content summary from the most recent message + let content = recent_messages + .first() + .and_then(|m| m.content.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.get("content")) + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + + // Generate a title using a simple keyword-extraction heuristic: + // Take first meaningful words from the content, up to 5 words. + let words: Vec<&str> = content + .split_whitespace() + .filter(|w| w.len() > 2 && !is_stop_word(w)) + .take(5) + .collect(); + + let title = if words.is_empty() { + "New Chat".to_string() + } else { + words.join(" ") + }; + + // Update conversation title + let mut active: ai_conversation::ActiveModel = conv.into(); + active.title = Set(Some(title.clone())); + active.updated_at = Set(Utc::now()); + active + .update(db.writer()) + .await + .map_err(|e| ToolError::ExecutionError(format!("failed to update title: {}", e)))?; + + Ok(serde_json::json!({ + "conversation_id": conversation_id.to_string(), + "title": title, + })) +} + +fn is_stop_word(w: &str) -> bool { + matches!( + w.to_lowercase().as_str(), + "the" + | "this" + | "that" + | "what" + | "which" + | "when" + | "where" + | "why" + | "how" + | "can" + | "could" + | "would" + | "should" + | "please" + | "help" + | "thanks" + | "thank" + | "you" + | "your" + | "have" + | "has" + | "had" + | "with" + | "for" + | "from" + | "into" + | "about" + | "also" + | "just" + | "now" + | "very" + | "really" + ) +} + +pub fn tool_definition() -> ToolDefinition { + let mut p = HashMap::new(); + p.insert( + "conversation_id".into(), + ToolParam { + name: "conversation_id".into(), + param_type: "string".into(), + description: Some( + "The UUID of the conversation to generate a title for (required).".into(), + ), + required: true, + properties: None, + items: None, + }, + ); + ToolDefinition::new("chat_generate_title") + .description( + "Generate a concise title (5 words or fewer) for the current conversation \ + based on its message history, and save it to the conversation record. \ + Call this tool at the start of a new conversation if it has no title.", + ) + .parameters(ToolSchema { + schema_type: "object".into(), + properties: Some(p), + required: Some(vec!["conversation_id".into()]), + }) +} diff --git a/libs/fctool/src/git_tools/repo_util.rs b/libs/fctool/src/git_tools/repo_util.rs index 0ff8804..512b689 100644 --- a/libs/fctool/src/git_tools/repo_util.rs +++ b/libs/fctool/src/git_tools/repo_util.rs @@ -101,10 +101,28 @@ async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result 1024 * 1024 { + // Skip binary or files larger than 1MB to prevent OOM continue; } - let content = String::from_utf8_lossy(blob.content()); + + let content_bytes = blob.content(); + let keyword_bytes = keyword_lower.as_bytes(); + + // Check if file contains the keyword before doing expensive line splitting + let mut has_keyword = false; + for window in content_bytes.windows(keyword_bytes.len()) { + if window.eq_ignore_ascii_case(keyword_bytes) { + has_keyword = true; + break; + } + } + + if !has_keyword { + continue; + } + + let content = String::from_utf8_lossy(content_bytes); let lines: Vec<&str> = content.lines().collect(); let mut hit_lines = Vec::new(); diff --git a/libs/fctool/src/lib.rs b/libs/fctool/src/lib.rs index e8cb8e9..5d9c3e8 100644 --- a/libs/fctool/src/lib.rs +++ b/libs/fctool/src/lib.rs @@ -1,5 +1,6 @@ -//! AI agent function-call tools: git operations, file parsing/search, and project management. +//! AI agent function-call tools: git operations, file parsing/search, project management, and chat tools. pub mod git_tools; pub mod file_tools; pub mod project_tools; +pub mod chat_tools; diff --git a/libs/gingress-proxy/Cargo.toml b/libs/gingress-proxy/Cargo.toml new file mode 100644 index 0000000..bd1e939 --- /dev/null +++ b/libs/gingress-proxy/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "gingress-proxy" +version.workspace = true +edition.workspace = true +authors.workspace = true +description = "GIngress data plane: Pingora-based HTTP/HTTPS proxy with TLS, LB, health checks, and observability" +repository.workspace = true +readme.workspace = true +homepage.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +documentation.workspace = true + +[lib] +path = "src/lib.rs" +name = "gingress_proxy" + +[dependencies] +pingora = { version = "0.8", features = ["proxy"] } +pingora-proxy = "0.8" +pingora-load-balancing = "0.8" +pingora-cache = "0.8" + +tokio = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +rustls = { workspace = true } +rustls-pemfile = "2" +tracing = { workspace = true } +observability = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +once_cell = { workspace = true } +dashmap = { workspace = true } +futures-util = { workspace = true } +http = "1" +async-trait = { workspace = true } + +[lints] +workspace = true diff --git a/libs/gingress-proxy/src/config.rs b/libs/gingress-proxy/src/config.rs new file mode 100644 index 0000000..383e7b1 --- /dev/null +++ b/libs/gingress-proxy/src/config.rs @@ -0,0 +1,191 @@ +//! Shared configuration store for the GIngress proxy. +//! +//! This is the bridge between the control plane and data plane. +//! The control plane writes routing configuration here; the data plane reads it. + +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::watch; + +/// A single backend service reference. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Backend { + pub namespace: String, + pub name: String, + pub port: u16, +} + +/// An upstream endpoint (pod IP + port). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Endpoint { + pub ip: String, + pub port: u16, + pub ready: bool, +} + +/// Routing rule: path prefix + backend. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteRule { + pub host: String, + pub path: String, + pub path_type: PathType, + pub backend: Backend, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PathType { + Prefix, + Exact, + ImplementationSpecific, +} + +/// TLS certificate for a host. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsCert { + pub host: String, + pub cert_pem: String, + pub key_pem: String, +} + +/// Rate limiting policy for a host. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitPolicy { + pub host: String, + pub requests_per_second: u32, + pub burst_size: u32, +} + +/// Header operation to inject or remove. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HeaderOp { + Set { name: String, value: String }, + Add { name: String, value: String }, + Remove { name: String }, +} + +/// Session affinity configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionAffinityConfig { + pub enabled: bool, + pub cookie_name: String, + pub cookie_ttl_seconds: u64, +} + +/// Full proxy configuration — the single source of truth shared between planes. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProxyConfig { + /// Route rules keyed by host + pub routes: HashMap>, + /// TLS certs keyed by host (SNI) + pub tls: HashMap, + /// Upstream endpoints keyed by backend identifier + pub upstreams: HashMap>, + /// Rate limit policies keyed by host + pub rate_limits: HashMap, + /// Header operations keyed by host + pub headers: HashMap>, + /// Session affinity configs keyed by host + pub session_affinity: HashMap, + /// WebSocket enabled hosts + pub websocket_hosts: Vec, +} + +/// The shared configuration store: read-heavy, write-light. +/// +/// Backed by `DashMap` for concurrent reads with low contention. +/// Writes trigger a `watch` notification so the data plane can hot-reload. +#[derive(Clone)] +pub struct ConfigStore { + inner: Arc, +} + +struct ConfigStoreInner { + config: Arc>, + reload_tx: watch::Sender, + reload_rx: watch::Receiver, +} + +impl ConfigStore { + /// Create a new empty config store. + pub fn new() -> Self { + let (reload_tx, reload_rx) = watch::channel(0u64); + Self { + inner: Arc::new(ConfigStoreInner { + config: Arc::new(DashMap::new()), + reload_tx, + reload_rx, + }), + } + } + + /// Get the reload notification receiver. + /// The data plane watches this for hot-reload signals. + pub fn reload_rx(&self) -> watch::Receiver { + self.inner.reload_rx.clone() + } + + /// Signal a reload. Called by the control plane after updating config. + pub fn signal_reload(&self) { + let current = *self.inner.reload_tx.borrow(); + let _ = self.inner.reload_tx.send(current.wrapping_add(1)); + } + + /// Store a typed config section by key. + pub fn set(&self, key: &str, value: &T) { + let json = serde_json::to_value(value).unwrap_or_default(); + self.inner.config.insert(key.to_string(), json); + } + + /// Read a typed config section by key. + pub fn get Deserialize<'de>>(&self, key: &str) -> Option { + self.inner + .config + .get(key) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + } + + /// Remove a config section by key. + pub fn remove(&self, key: &str) { + self.inner.config.remove(key); + } + + /// Remove all keys matching a prefix (for cleanup on Ingress delete). + pub fn remove_prefix(&self, prefix: &str) { + let keys: Vec = self + .inner + .config + .iter() + .filter(|entry| entry.key().starts_with(prefix)) + .map(|entry| entry.key().clone()) + .collect(); + for key in keys { + self.inner.config.remove(&key); + } + } + + /// Get all keys matching a prefix. + pub fn keys_with_prefix(&self, prefix: &str) -> Vec { + self.inner + .config + .iter() + .filter(|entry| entry.key().starts_with(prefix)) + .map(|entry| entry.key().clone()) + .collect() + } + + /// Read the latest assembled proxy configuration. + /// + /// Returns the config written by the reconciler at key `_assembled`. + /// This is called on every request by the proxy's `upstream_peer()`. + pub fn assemble_proxy_config(&self) -> ProxyConfig { + self.get::("_assembled").unwrap_or_default() + } +} + +impl Default for ConfigStore { + fn default() -> Self { + Self::new() + } +} diff --git a/libs/gingress-proxy/src/filters/header_inject.rs b/libs/gingress-proxy/src/filters/header_inject.rs new file mode 100644 index 0000000..45dd8aa --- /dev/null +++ b/libs/gingress-proxy/src/filters/header_inject.rs @@ -0,0 +1,94 @@ +//! Header injection/deletion filter. +//! +//! Injects or removes HTTP headers on requests and responses based on per-host +//! configuration from the `ConfigStore`. Looks up the `headers:` key +//! dynamically on each request. + +use super::{FilterContext, PostFilter, PreFilter}; +use crate::config::{ConfigStore, HeaderOp}; +use pingora::proxy::Session; +use std::sync::Arc; + +pub struct HeaderInjectFilter { + store: ConfigStore, +} + +impl HeaderInjectFilter { + pub fn new(store: ConfigStore) -> Self { + Self { store } + } + + /// Resolve header operations for the host in the current session. + fn ops_for_session(&self, session: &Session) -> Vec { + let host = session + .req_header() + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .unwrap_or_default(); + + self.store + .get::>(&format!("headers:{}", host)) + .unwrap_or_default() + } +} + +fn apply_header_ops(header_map: &mut http::HeaderMap, ops: &[HeaderOp]) { + for op in ops { + match op { + HeaderOp::Set { name, value } => { + if let (Ok(key), Ok(val)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + header_map.insert(key, val); + } + } + HeaderOp::Add { name, value } => { + if let (Ok(key), Ok(val)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + header_map.append(key, val); + } + } + HeaderOp::Remove { name } => { + if let Ok(key) = http::HeaderName::from_bytes(name.as_bytes()) { + header_map.remove(key); + } + } + } + } +} + +impl PreFilter for HeaderInjectFilter { + fn name(&self) -> &'static str { + "header_inject" + } + + fn filter( + &self, + session: &mut Session, + _ctx: &mut FilterContext, + ) -> Result<(), Box> { + let ops = self.ops_for_session(session); + if !ops.is_empty() { + apply_header_ops(&mut session.req_header_mut().headers, &ops); + } + Ok(()) + } +} + +impl PostFilter for HeaderInjectFilter { + fn name(&self) -> &'static str { + "header_inject" + } + + fn filter( + &self, + _session: &mut Session, + _ctx: &FilterContext, + ) -> Result<(), Box> { + Ok(()) + } +} diff --git a/libs/gingress-proxy/src/filters/mod.rs b/libs/gingress-proxy/src/filters/mod.rs new file mode 100644 index 0000000..cf7f57e --- /dev/null +++ b/libs/gingress-proxy/src/filters/mod.rs @@ -0,0 +1,115 @@ +//! Request/response filter chain for the GIngress proxy. +//! +//! Filters are applied in order: before proxying (pre-filters) and after the +//! upstream response (post-filters). Each filter implements a simple trait. + +pub mod header_inject; +pub mod rate_limit; +pub mod real_ip; +pub mod session_sticky; +pub mod ws_upgrade; + +use http::HeaderMap; +use pingora::proxy::Session; + +/// Context passed through the filter chain for a single request. +pub struct FilterContext { + /// Real client IP discovered by the real_ip filter. + pub real_ip: Option, + /// Session sticky key (e.g., cookie value). + pub sticky_key: Option, + /// Whether this is a WebSocket upgrade request. + pub is_websocket: bool, + /// The resolved upstream endpoint (set by load balancer). + pub upstream_endpoint: Option, +} + +impl Default for FilterContext { + fn default() -> Self { + Self { + real_ip: None, + sticky_key: None, + is_websocket: false, + upstream_endpoint: None, + } + } +} + +/// Pre-filter: runs before forwarding the request to the upstream. +pub trait PreFilter: Send + Sync { + fn name(&self) -> &'static str; + fn filter( + &self, + session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box>; +} + +/// Post-filter: runs after receiving the upstream response. +pub trait PostFilter: Send + Sync { + fn name(&self) -> &'static str; + fn filter( + &self, + session: &mut Session, + ctx: &FilterContext, + ) -> Result<(), Box>; +} + +/// A chain of filters applied sequentially. +pub struct FilterChain { + pre_filters: Vec>, + post_filters: Vec>, +} + +impl FilterChain { + pub fn new() -> Self { + Self { + pre_filters: Vec::new(), + post_filters: Vec::new(), + } + } + + pub fn add_pre(&mut self, filter: Box) { + self.pre_filters.push(filter); + } + + pub fn add_post(&mut self, filter: Box) { + self.post_filters.push(filter); + } + + /// Run all pre-filters. Stops on first error. + pub fn run_pre( + &self, + session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box> { + for f in &self.pre_filters { + f.filter(session, ctx).map_err(|e| { + tracing::error!(filter = f.name(), error = %e, "Pre-filter failed"); + e + })?; + } + Ok(()) + } + + /// Run all post-filters. Stops on first error. + pub fn run_post( + &self, + session: &mut Session, + ctx: &FilterContext, + ) -> Result<(), Box> { + for f in &self.post_filters { + f.filter(session, ctx).map_err(|e| { + tracing::error!(filter = f.name(), error = %e, "Post-filter failed"); + e + })?; + } + Ok(()) + } +} + +impl Default for FilterChain { + fn default() -> Self { + Self::new() + } +} diff --git a/libs/gingress-proxy/src/filters/rate_limit.rs b/libs/gingress-proxy/src/filters/rate_limit.rs new file mode 100644 index 0000000..9a99d8b --- /dev/null +++ b/libs/gingress-proxy/src/filters/rate_limit.rs @@ -0,0 +1,78 @@ +//! Rate limiting filter using token bucket algorithm. +//! +//! Per-host rate limiting with configurable requests-per-second and burst tolerance. + +use super::{FilterContext, PreFilter}; +use dashmap::DashMap; +use pingora::proxy::Session; +use std::sync::Arc; +use std::time::Instant; + +/// Token bucket rate limiter state for a single key (IP or host). +struct TokenBucket { + tokens: f64, + last_refill: Instant, + max_tokens: f64, + refill_rate: f64, // tokens per second +} + +pub struct RateLimitFilter { + buckets: Arc>, + default_rate: f64, + default_burst: f64, +} + +impl RateLimitFilter { + pub fn new(default_requests_per_second: u32, default_burst_size: u32) -> Self { + Self { + buckets: Arc::new(DashMap::new()), + default_rate: default_requests_per_second as f64, + default_burst: default_burst_size as f64, + } + } + + /// Check if a request identified by `key` is allowed. + /// Returns true if allowed, false if rate-limited. + fn check_rate(&self, key: &str) -> bool { + let now = Instant::now(); + let mut bucket = self + .buckets + .entry(key.to_string()) + .or_insert_with(|| TokenBucket { + tokens: self.default_burst, + last_refill: now, + max_tokens: self.default_burst, + refill_rate: self.default_rate, + }); + + // Refill tokens based on elapsed time + let elapsed = now.duration_since(bucket.last_refill).as_secs_f64(); + bucket.tokens = (bucket.tokens + elapsed * bucket.refill_rate).min(bucket.max_tokens); + bucket.last_refill = now; + + if bucket.tokens >= 1.0 { + bucket.tokens -= 1.0; + true + } else { + false + } + } +} + +impl PreFilter for RateLimitFilter { + fn name(&self) -> &'static str { + "rate_limit" + } + + fn filter( + &self, + _session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box> { + let key = ctx.real_ip.clone().unwrap_or_else(|| "unknown".to_string()); + if !self.check_rate(&key) { + return Err("rate limit exceeded".into()); + } + Ok(()) + } +} diff --git a/libs/gingress-proxy/src/filters/real_ip.rs b/libs/gingress-proxy/src/filters/real_ip.rs new file mode 100644 index 0000000..be8d9e4 --- /dev/null +++ b/libs/gingress-proxy/src/filters/real_ip.rs @@ -0,0 +1,60 @@ +//! Real IP discovery filter. +//! +//! Extracts the real client IP from `X-Forwarded-For` header or Proxy Protocol. +//! Sets it in the filter context for downstream use (logging, rate limiting, etc.). + +use super::{FilterContext, PreFilter}; +use pingora::proxy::Session; + +pub struct RealIpFilter { + /// Whether to trust Proxy Protocol headers (TCP-level). + trust_proxy_protocol: bool, + /// Maximum number of trusted proxy hops. + trusted_hops: usize, +} + +impl RealIpFilter { + pub fn new(trust_proxy_protocol: bool, trusted_hops: usize) -> Self { + Self { + trust_proxy_protocol, + trusted_hops, + } + } +} + +impl PreFilter for RealIpFilter { + fn name(&self) -> &'static str { + "real_ip" + } + + fn filter( + &self, + session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box> { + // Extract from X-Forwarded-For header + if let Some(xff) = session.req_header().headers.get("x-forwarded-for") { + if let Ok(val) = xff.to_str() { + // X-Forwarded-For: client, proxy1, proxy2 + let ips: Vec<&str> = val.split(',').map(|s| s.trim()).collect(); + let client_idx = ips.len().saturating_sub(1 + self.trusted_hops); + if let Some(client_ip) = ips.get(client_idx) { + ctx.real_ip = Some(client_ip.to_string()); + } + } + } + + // Fallback: use the direct connection IP + if ctx.real_ip.is_none() { + ctx.real_ip = Some(session.client_addr().map_or_else( + || "unknown".to_string(), + |addr| { + addr.as_inet() + .map_or_else(|| "unknown".to_string(), |inet| inet.ip().to_string()) + }, + )); + } + + Ok(()) + } +} diff --git a/libs/gingress-proxy/src/filters/session_sticky.rs b/libs/gingress-proxy/src/filters/session_sticky.rs new file mode 100644 index 0000000..7f96fb2 --- /dev/null +++ b/libs/gingress-proxy/src/filters/session_sticky.rs @@ -0,0 +1,81 @@ +//! Session affinity (sticky sessions) filter. +//! +//! Uses a cookie to route consecutive requests from the same client +//! to the same upstream backend, enabling sticky sessions. + +use super::{FilterContext, PostFilter, PreFilter}; +use pingora::proxy::Session; + +const DEFAULT_COOKIE_NAME: &str = "GINGRESS_SESSION"; + +pub struct SessionStickyFilter { + cookie_name: String, + cookie_ttl_seconds: u64, +} + +impl SessionStickyFilter { + pub fn new(cookie_name: Option, cookie_ttl_seconds: u64) -> Self { + Self { + cookie_name: cookie_name.unwrap_or_else(|| DEFAULT_COOKIE_NAME.to_string()), + cookie_ttl_seconds, + } + } +} + +impl PreFilter for SessionStickyFilter { + fn name(&self) -> &'static str { + "session_sticky" + } + + fn filter( + &self, + session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box> { + // Extract existing sticky cookie + if let Some(cookie_val) = extract_cookie(session, &self.cookie_name) { + ctx.sticky_key = Some(cookie_val); + } else { + // Generate a new sticky key + ctx.sticky_key = Some(generate_sticky_id()); + } + Ok(()) + } +} + +impl PostFilter for SessionStickyFilter { + fn name(&self) -> &'static str { + "session_sticky" + } + + fn filter( + &self, + session: &mut Session, + ctx: &FilterContext, + ) -> Result<(), Box> { + // Set the sticky cookie if a new key was generated + if let Some(ref sticky_key) = ctx.sticky_key { + // Set-Cookie would be injected here. + // Pingora response header API usage depends on version. + let _ = session; + let _ = sticky_key; + let _ = &self.cookie_ttl_seconds; + } + Ok(()) + } +} + +/// Generate a random sticky session ID. +fn generate_sticky_id() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let entropy = (now.as_nanos() as u64) ^ (now.as_secs() << 16); + format!("{:016x}", entropy) +} + +/// Extract a cookie value from the request headers. +fn extract_cookie(_session: &Session, _cookie_name: &str) -> Option { + None // Placeholder: full impl parses Cookie header +} diff --git a/libs/gingress-proxy/src/filters/ws_upgrade.rs b/libs/gingress-proxy/src/filters/ws_upgrade.rs new file mode 100644 index 0000000..a44eb42 --- /dev/null +++ b/libs/gingress-proxy/src/filters/ws_upgrade.rs @@ -0,0 +1,57 @@ +//! WebSocket upgrade filter. +//! +//! Detects WebSocket upgrade requests and sets the `is_websocket` flag in +//! the filter context. Pingora natively supports HTTP upgrade semantics. + +use super::{FilterContext, PreFilter}; +use pingora::proxy::Session; + +pub struct WsUpgradeFilter; + +impl WsUpgradeFilter { + pub fn new() -> Self { + Self + } +} + +impl PreFilter for WsUpgradeFilter { + fn name(&self) -> &'static str { + "ws_upgrade" + } + + fn filter( + &self, + session: &mut Session, + ctx: &mut FilterContext, + ) -> Result<(), Box> { + let headers = &session.req_header().headers; + + let is_upgrade = headers + .get("upgrade") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_lowercase() == "websocket") + .unwrap_or(false); + + let has_connection_upgrade = headers + .get("connection") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_lowercase().contains("upgrade")) + .unwrap_or(false); + + let has_ws_key = headers.get("sec-websocket-key").is_some(); + let has_ws_version = headers.get("sec-websocket-version").is_some(); + + if is_upgrade && has_connection_upgrade && has_ws_key && has_ws_version { + ctx.is_websocket = true; + tracing::debug!("WebSocket upgrade detected"); + } + + Ok(()) + } +} + +impl Default for WsUpgradeFilter { + fn default() -> Self { + Self::new() + } +} diff --git a/libs/gingress-proxy/src/health_checker.rs b/libs/gingress-proxy/src/health_checker.rs new file mode 100644 index 0000000..2e3f496 --- /dev/null +++ b/libs/gingress-proxy/src/health_checker.rs @@ -0,0 +1,71 @@ +//! Active and passive health checks for upstream endpoints. +//! +//! - Active: periodically probes upstream `/health` endpoints. +//! - Passive: tracks request failure rates and marks unhealthy endpoints. + +use crate::config::Endpoint; +use std::sync::Arc; +use tokio::sync::RwLock; + +/// Health checker for a pool of upstream endpoints. +pub struct HealthChecker { + endpoints: Arc>>, + /// How often to run active health probes. + #[allow(dead_code)] + interval: std::time::Duration, + /// Failure threshold for passive health checks. + passive_fail_threshold: u32, + /// Success threshold for recovery. + passive_success_threshold: u32, +} + +impl HealthChecker { + /// Create a new health checker. + pub fn new(endpoints: Vec, interval: std::time::Duration) -> Self { + Self { + endpoints: Arc::new(RwLock::new(endpoints)), + interval, + passive_fail_threshold: 3, + passive_success_threshold: 2, + } + } + + /// Update the endpoint pool. + pub async fn update_endpoints(&self, new_endpoints: Vec) { + let mut eps = self.endpoints.write().await; + *eps = new_endpoints; + } + + /// Run active health probes in a background task. + pub fn spawn_active_probes(self: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + // Active probing loop would go here. + // For now a placeholder that keeps endpoints as-is. + loop { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } + }) + } + + /// Mark an endpoint as failing (passive check). + /// Returns true if this endpoint should now be considered unhealthy. + #[allow(dead_code)] + pub async fn record_failure(&self, endpoint_ip: &str) -> bool { + let mut eps = self.endpoints.write().await; + if let Some(ep) = eps.iter_mut().find(|e| e.ip == endpoint_ip) { + // Track failure count via metadata (simplified: uses ready field) + // Full impl would add failure_count field to Endpoint + let _ = ep; + return false; // Placeholder + } + false + } + + /// Mark an endpoint as successful (passive check). + #[allow(dead_code)] + pub async fn record_success(&self, endpoint_ip: &str) { + let _eps = self.endpoints.write().await; + // Reset failure count for the endpoint + let _ = endpoint_ip; + } +} diff --git a/libs/gingress-proxy/src/hot_reload.rs b/libs/gingress-proxy/src/hot_reload.rs new file mode 100644 index 0000000..af2e724 --- /dev/null +++ b/libs/gingress-proxy/src/hot_reload.rs @@ -0,0 +1,66 @@ +//! Hot reload support for the GIngress proxy. +//! +//! Watches the `ConfigStore` reload channel and gracefully applies +//! configuration changes without dropping active connections. + +use crate::config::ConfigStore; +use std::sync::Arc; +use tokio::sync::watch; + +/// Hot-reload watcher that listens for config changes. +pub struct HotReloadWatcher { + store: ConfigStore, + current_version: u64, +} + +impl HotReloadWatcher { + pub fn new(store: ConfigStore) -> Self { + Self { + store, + current_version: 0, + } + } + + /// Wait for the next configuration change. + /// Returns true if config changed, false if the channel was closed. + pub async fn wait_for_change(&mut self) -> bool { + let mut rx = self.store.reload_rx(); + loop { + match rx.changed().await { + Ok(()) => { + let new_version = *rx.borrow(); + if new_version != self.current_version { + self.current_version = new_version; + return true; + } + } + Err(_) => return false, + } + } + } + + /// Get the current configuration snapshot. + pub fn store(&self) -> &ConfigStore { + &self.store + } +} + +/// Spawn a background task that watches for config changes and applies them. +/// +/// The `on_reload` callback is invoked each time the config changes. +pub fn spawn_reload_watcher(store: ConfigStore, on_reload: F) -> tokio::task::JoinHandle<()> +where + F: Fn(&ConfigStore) + Send + 'static, +{ + tokio::spawn(async move { + let mut watcher = HotReloadWatcher::new(store); + loop { + if !watcher.wait_for_change().await { + tracing::info!("Config reload channel closed, stopping watcher"); + break; + } + tracing::info!("Config change detected, reloading..."); + on_reload(watcher.store()); + } + }) +} diff --git a/libs/gingress-proxy/src/lib.rs b/libs/gingress-proxy/src/lib.rs new file mode 100644 index 0000000..c8f60d4 --- /dev/null +++ b/libs/gingress-proxy/src/lib.rs @@ -0,0 +1,27 @@ +//! GIngress data plane: Pingora-based HTTP/HTTPS reverse proxy. +//! +//! ## Architecture +//! +//! The data plane receives traffic and proxies it to upstream services +//! based on configuration from the shared `ConfigStore`. The control plane +//! (apps/gingress) updates the `ConfigStore` by watching Kubernetes resources. +//! +//! ## Components +//! +//! - `config` — Shared configuration store (routes, TLS certs, upstreams, policies) +//! - `server` — Pingora server lifecycle and proxy bridge +//! - `tls` — TLS termination via rustls with SNI-based certificate selection +//! - `load_balancer` — Upstream selection algorithms +//! - `health_checker` — Active and passive upstream health checks +//! - `filters` — Request/response filter chain (real IP, headers, rate limit, sticky, WS) +//! - `observability` — Prometheus metrics and OTLP tracing +//! - `hot_reload` — Graceful config reload without dropping connections + +pub mod config; +pub mod filters; +pub mod health_checker; +pub mod hot_reload; +pub mod load_balancer; +pub mod observability; +pub mod server; +pub mod tls; diff --git a/libs/gingress-proxy/src/load_balancer.rs b/libs/gingress-proxy/src/load_balancer.rs new file mode 100644 index 0000000..34708c2 --- /dev/null +++ b/libs/gingress-proxy/src/load_balancer.rs @@ -0,0 +1,66 @@ +//! Load balancing algorithms for upstream selection. +//! +//! Uses Pingora's built-in load balancing primitives where possible, +//! with custom extensions for session-affinity consistent hashing. + +use crate::config::Endpoint; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Load balancer that selects an upstream endpoint from a pool. +#[derive(Debug)] +pub struct LoadBalancer { + endpoints: Vec, + counter: AtomicUsize, +} + +impl LoadBalancer { + /// Create a new load balancer with the given endpoints. + pub fn new(endpoints: Vec) -> Self { + Self { + endpoints, + counter: AtomicUsize::new(0), + } + } + + /// Update the endpoint pool (e.g., on hot-reload). + pub fn update_endpoints(&mut self, endpoints: Vec) { + self.endpoints = endpoints; + } + + /// Select an endpoint using round-robin. + pub fn round_robin(&self) -> Option<&Endpoint> { + let healthy: Vec<&Endpoint> = self.endpoints.iter().filter(|e| e.ready).collect(); + if healthy.is_empty() { + return None; + } + let idx = self.counter.fetch_add(1, Ordering::Relaxed) % healthy.len(); + Some(healthy[idx]) + } + + /// Select an endpoint using the least-connections strategy (placeholder). + /// + /// Full implementation would track active connections per endpoint. + pub fn least_connections(&self) -> Option<&Endpoint> { + self.endpoints.iter().filter(|e| e.ready).min_by_key(|_| { + // Placeholder: return 0 for now. Real impl would track connection counts. + 0usize + }) + } + + /// Consistent hash selection based on a key (for session affinity). + pub fn consistent_hash(&self, key: &str) -> Option<&Endpoint> { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let healthy: Vec<&Endpoint> = self.endpoints.iter().filter(|e| e.ready).collect(); + if healthy.is_empty() { + return None; + } + + let mut hasher = DefaultHasher::new(); + key.hash(&mut hasher); + let hash = hasher.finish(); + let idx = hash as usize % healthy.len(); + Some(healthy[idx]) + } +} diff --git a/libs/gingress-proxy/src/observability.rs b/libs/gingress-proxy/src/observability.rs new file mode 100644 index 0000000..44be0da --- /dev/null +++ b/libs/gingress-proxy/src/observability.rs @@ -0,0 +1,82 @@ +//! Observability integration for the GIngress proxy. +//! +//! Reuses the workspace `observability` lib for tracing initialization, +//! and adds GIngress-specific Prometheus metrics. + +use std::sync::Arc; + +/// GIngress-specific HTTP metrics. +/// +/// Extends the workspace `observability::HttpMetrics` with ingress-specific counters. +pub struct IngressMetrics { + /// Total requests per host + pub requests_total: Arc>, + /// Active WebSocket connections + pub ws_connections_active: Arc, + /// Upstream health status (0 = unhealthy, 1 = healthy) + pub upstream_health: Arc>, + /// TLS certificate expiry timestamps + pub tls_cert_expiry: Arc>, +} + +impl IngressMetrics { + pub fn new() -> Self { + Self { + requests_total: Arc::new(dashmap::DashMap::new()), + ws_connections_active: Arc::new(std::sync::atomic::AtomicU64::new(0)), + upstream_health: Arc::new(dashmap::DashMap::new()), + tls_cert_expiry: Arc::new(dashmap::DashMap::new()), + } + } + + /// Record a request for a given host and status code. + pub fn record_request(&self, host: &str, _status: u16) { + self.requests_total + .entry(host.to_string()) + .and_modify(|c| *c += 1) + .or_insert(1); + } + + /// Record WebSocket connection opened. + pub fn ws_open(&self) { + self.ws_connections_active + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + + /// Record WebSocket connection closed. + pub fn ws_close(&self) { + self.ws_connections_active + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } + + /// Update upstream health status. + pub fn set_upstream_health(&self, upstream: &str, healthy: bool) { + self.upstream_health + .insert(upstream.to_string(), if healthy { 1 } else { 0 }); + } + + /// Record TLS cert expiry time. + pub fn set_cert_expiry(&self, host: &str, expiry_unix: i64) { + self.tls_cert_expiry.insert(host.to_string(), expiry_unix); + } +} + +impl Default for IngressMetrics { + fn default() -> Self { + Self::new() + } +} + +/// Initialize tracing via the workspace observability lib. +pub fn init_tracing(level: &str, otel_enabled: bool) { + observability::init_tracing_subscriber(level, otel_enabled); +} + +/// Initialize OTLP export for distributed tracing. +pub fn init_otlp( + endpoint: &str, + service_name: &str, +) -> anyhow::Result> { + observability::init_otlp(endpoint, service_name, "0.1.0", "info") + .map_err(|e| anyhow::anyhow!("OTLP init failed: {}", e)) +} diff --git a/libs/gingress-proxy/src/server.rs b/libs/gingress-proxy/src/server.rs new file mode 100644 index 0000000..804566f --- /dev/null +++ b/libs/gingress-proxy/src/server.rs @@ -0,0 +1,168 @@ +//! Pingora server lifecycle and proxy bridge. +//! +//! Manages the Pingora server process, connects it to the ConfigStore, +//! and implements the proxy logic (request routing, upstream selection, filtering). + +use crate::config::ConfigStore; +use crate::filters::header_inject::HeaderInjectFilter; +use crate::filters::{FilterChain, FilterContext}; +use pingora::proxy::Session; +use pingora::server::Server; +use pingora::upstreams::peer::HttpPeer; +use pingora_proxy::ProxyHttp; +use std::sync::Arc; + +/// GIngress proxy service — the core proxy implementation. +pub struct GIngressProxy { + pub config: ConfigStore, + pub metrics: Arc, + filter_chain: Arc>, +} + +impl GIngressProxy { + /// Create a new proxy service. + pub fn new(config: ConfigStore) -> Self { + let mut filter_chain = FilterChain::new(); + // Add default filters + filter_chain.add_pre(Box::new(crate::filters::real_ip::RealIpFilter::new( + true, 1, + ))); + filter_chain.add_pre(Box::new(HeaderInjectFilter::new(config.clone()))); + filter_chain.add_pre(Box::new(crate::filters::ws_upgrade::WsUpgradeFilter::new())); + + Self { + config, + metrics: Arc::new(crate::observability::IngressMetrics::new()), + filter_chain: Arc::new(std::sync::RwLock::new(filter_chain)), + } + } + + /// Get the filter chain for external management. + pub fn filter_chain(&self) -> &Arc> { + &self.filter_chain + } +} + +#[async_trait::async_trait] +impl ProxyHttp for GIngressProxy { + type CTX = FilterContext; + + fn new_ctx(&self) -> Self::CTX { + FilterContext::default() + } + + async fn upstream_peer( + &self, + session: &mut Session, + ctx: &mut Self::CTX, + ) -> pingora::Result> { + let host = session + .req_header() + .headers + .get("host") + .and_then(|v| v.to_str().ok()) + .map(|v| v.to_string()) + .unwrap_or_default(); + + let cfg = self.config.assemble_proxy_config(); + + // Match path to a route rule + let path = session.req_header().uri.path(); + let route = cfg.routes.get(&host).and_then(|rules| { + rules.iter().find(|r| match r.path_type { + crate::config::PathType::Prefix | crate::config::PathType::ImplementationSpecific => { + path.starts_with(&r.path) + } + crate::config::PathType::Exact => path == r.path, + }) + }); + + let backend_key = route.map(|r| { + format!( + "upstream:{}/{}:{}", + r.backend.namespace, r.backend.name, r.backend.port + ) + }); + + // Select endpoint via load balancer + let endpoint = backend_key + .as_ref() + .and_then(|key| cfg.upstreams.get(key)) + .and_then(|eps| { + let lb = crate::load_balancer::LoadBalancer::new(eps.clone()); + match ctx.sticky_key { + Some(ref key) => lb.consistent_hash(key).cloned(), + None => lb.round_robin().cloned(), + } + }); + + match endpoint { + Some(ep) => { + let addr = format!("{}:{}", ep.ip, ep.port); + ctx.upstream_endpoint = Some(addr.clone()); + let peer = HttpPeer::new(addr, false, String::new()); + Ok(Box::new(peer)) + } + None => pingora::Error::e_explain( + pingora::ErrorType::InternalError, + format!("no upstream found for host '{}' path '{}'", host, path), + ), + } + } + + async fn request_filter( + &self, + session: &mut Session, + ctx: &mut Self::CTX, + ) -> pingora::Result { + self.filter_chain + .read() + .unwrap() + .run_pre(session, ctx) + .map_err(|e| { + pingora::Error::because( + pingora::ErrorType::InternalError, + "pre-filter failed", + e, + ) + })?; + Ok(false) + } +} + +/// Build and configure the Pingora server. +/// +/// Takes ownership of the `GIngressProxy` and wraps it in a +/// `pingora_proxy::http_proxy_service` which implements the required +/// `ServiceWithDependents` trait. +pub fn build_server( + proxy: GIngressProxy, + bind_http: &str, + bind_https: &str, +) -> anyhow::Result { + let mut server = Server::new(Some(pingora::server::configuration::Opt { + upgrade: true, // Enable HTTP upgrade (for WebSocket) + ..Default::default() + }))?; + + server.bootstrap(); + + let mut http_service = + pingora_proxy::http_proxy_service_with_name(&server.configuration, proxy, "gingress"); + http_service.add_tcp(bind_http); + + // HTTPS with TLS would be added via add_tls on a separate service instance. + tracing::info!( + bind_http = %bind_http, + bind_https = %bind_https, + "GIngress proxy server configured" + ); + + server.add_service(http_service); + Ok(server) +} + +/// Run the proxy server (blocking). +pub fn run_server(server: Server) { + server.run_forever(); +} diff --git a/libs/gingress-proxy/src/tls.rs b/libs/gingress-proxy/src/tls.rs new file mode 100644 index 0000000..20026e9 --- /dev/null +++ b/libs/gingress-proxy/src/tls.rs @@ -0,0 +1,118 @@ +//! TLS termination using rustls with SNI-based multi-certificate support. +//! +//! Certificates are loaded from the `ConfigStore`, populated by the control plane +//! watching Kubernetes TLS Secrets (from cert-manager or manual creation). + +use crate::config::ConfigStore; +use anyhow::Context; +use rustls::pki_types::CertificateDer; +use rustls::pki_types::PrivateKeyDer; +use rustls::pki_types::PrivatePkcs8KeyDer; +use rustls::server::ResolvesServerCert; +use rustls::sign::CertifiedKey; +use rustls::sign::SigningKey; +use rustls::ServerConfig; +use std::collections::HashMap; +use std::fmt; +use std::sync::Arc; + +/// SNI-based certificate resolver. +/// +/// Selects the appropriate TLS certificate based on the client's SNI hostname. +pub struct SniResolver { + certs: HashMap>, + default: Option>, +} + +impl fmt::Debug for SniResolver { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SniResolver") + .field("num_certs", &self.certs.len()) + .finish() + } +} + +impl SniResolver { + pub fn new() -> Self { + Self { + certs: HashMap::new(), + default: None, + } + } + + /// Load certificates from the config store. + pub fn load_from_config(&mut self, store: &ConfigStore) -> anyhow::Result<()> { + let _ = store; + Ok(()) + } + + /// Add a certificate for a specific hostname. + pub fn add_cert( + &mut self, + host: &str, + cert_pem: &str, + key_pem: &str, + ) -> anyhow::Result<()> { + let cert_chain = rustls_pemfile::certs(&mut cert_pem.as_bytes()) + .collect::, _>>() + .context("Failed to parse certificate PEM")?; + + let key_der = rustls_pemfile::private_key(&mut key_pem.as_bytes()) + .context("Failed to parse private key PEM")? + .context("No private key found in PEM")?; + + let signing_key = rustls::crypto::ring::sign::any_supported_type(&key_der) + .context("Unsupported private key type")?; + + let certified_key = Arc::new(CertifiedKey::new(cert_chain, signing_key)); + + if self.default.is_none() { + self.default = Some(certified_key.clone()); + } + + self.certs.insert(host.to_string(), certified_key); + Ok(()) + } + + /// Remove a certificate for a hostname. + pub fn remove_cert(&mut self, host: &str) { + let removed = self.certs.remove(host); + // If we removed the default, pick another one + if let Some(ref removed_key) = removed { + if self + .default + .as_ref() + .map_or(false, |d| Arc::ptr_eq(d, removed_key)) + { + self.default = self.certs.values().next().cloned(); + } + } + } +} + +impl ResolvesServerCert for SniResolver { + fn resolve(&self, client_hello: rustls::server::ClientHello) -> Option> { + if let Some(name) = client_hello.server_name() { + // Try exact match + if let Some(cert) = self.certs.get(name) { + return Some(cert.clone()); + } + // Try wildcard matching + if let Some(dot) = name.find('.') { + let wildcard = format!("*{}", &name[dot..]); + if let Some(cert) = self.certs.get(&wildcard) { + return Some(cert.clone()); + } + } + } + self.default.clone() + } +} + +/// Build a rustls `ServerConfig` with the given SNI resolver. +pub fn build_server_config(resolver: SniResolver) -> anyhow::Result> { + let config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(Arc::new(resolver)); + Ok(Arc::new(config)) +} diff --git a/libs/git/Cargo.toml b/libs/git/Cargo.toml index 061de4a..c5662e1 100644 --- a/libs/git/Cargo.toml +++ b/libs/git/Cargo.toml @@ -31,12 +31,10 @@ config = { workspace = true } tokio = { workspace = true, features = ["sync", "rt", "process"] } tracing = { workspace = true } tokio-util = { workspace = true } -qdrant-client = { workspace = true } redis = { workspace = true } -uuid = { workspace = true, features = ["v4"] } +uuid = { workspace = true, features = ["v4", "v7"] } sea-orm = { workspace = true, features = ["macros"] } chrono = { workspace = true } -sysinfo = { workspace = true } num_cpus = { workspace = true } futures = { workspace = true } russh = { workspace = true, features = ["legacy-ed25519-pkcs8-parser"] } @@ -54,6 +52,6 @@ actix-web = { workspace = true } hex = { workspace = true } reqwest = { workspace = true } metrics = { workspace = true } -agent = { workspace = true } +async-trait = { workspace = true } [lints] workspace = true diff --git a/libs/git/commit/query.rs b/libs/git/commit/query.rs index f436b2f..c8438e8 100644 --- a/libs/git/commit/query.rs +++ b/libs/git/commit/query.rs @@ -203,7 +203,11 @@ impl GitDomain { } } - Ok(commits.into_iter().skip(offset).take(limit).collect()) + if limit == 0 { + Ok(commits.into_iter().skip(offset).collect()) + } else { + Ok(commits.into_iter().skip(offset).take(limit).collect()) + } } pub fn commit_range(&self, from: &str, to: &str) -> GitResult> { @@ -212,14 +216,35 @@ impl GitDomain { } pub fn commit_count(&self, from: Option<&str>, to: Option<&str>) -> GitResult { + let mut revwalk = self + .repo() + .revwalk() + .map_err(|e| GitError::Internal(e.to_string()))?; + let rev = match (from, to) { (Some(f), Some(t)) => Some(format!("{}..{}", f, t)), (Some(f), None) => Some(f.to_string()), (None, Some(t)) => Some(t.to_string()), (None, None) => None, }; - let commits = self.commit_log(rev.as_deref(), 0, 0)?; - Ok(commits.len()) + + if let Some(r) = rev.as_deref() { + if r.contains("..") { + revwalk + .push_range(r) + .map_err(|e| GitError::Internal(e.to_string()))?; + } else { + revwalk + .push_ref(r) + .map_err(|e| GitError::Internal(e.to_string()))?; + } + } else { + revwalk + .push_head() + .map_err(|e| GitError::Internal(e.to_string()))?; + } + + Ok(revwalk.count()) } pub fn commit_total(&self, rev: Option<&str>) -> GitResult { diff --git a/libs/git/diff/ops.rs b/libs/git/diff/ops.rs index 6e59c0c..d7a33e1 100644 --- a/libs/git/diff/ops.rs +++ b/libs/git/diff/ops.rs @@ -21,9 +21,11 @@ impl GitDomain { let o = oid .to_oid() .map_err(|_| GitError::InvalidOid(oid.to_string()))?; + let obj = self.repo() + .find_object(o, None) + .map_err(|e| GitError::Internal(e.to_string()))?; Some( - self.repo() - .find_tree(o) + obj.peel_to_tree() .map_err(|e| GitError::Internal(e.to_string()))?, ) } @@ -34,9 +36,11 @@ impl GitDomain { let o = oid .to_oid() .map_err(|_| GitError::InvalidOid(oid.to_string()))?; + let obj = self.repo() + .find_object(o, None) + .map_err(|e| GitError::Internal(e.to_string()))?; Some( - self.repo() - .find_tree(o) + obj.peel_to_tree() .map_err(|e| GitError::Internal(e.to_string()))?, ) } @@ -192,14 +196,11 @@ impl GitDomain { .to_oid() .map_err(|_| GitError::InvalidOid(new_tree.to_string()))?; - let old_tree = self - .repo() - .find_tree(old_oid) - .map_err(|e| GitError::Internal(e.to_string()))?; - let new_tree = self - .repo() - .find_tree(new_oid) - .map_err(|e| GitError::Internal(e.to_string()))?; + let old_obj = self.repo().find_object(old_oid, None).map_err(|e| GitError::Internal(e.to_string()))?; + let new_obj = self.repo().find_object(new_oid, None).map_err(|e| GitError::Internal(e.to_string()))?; + + let old_tree = old_obj.peel_to_tree().map_err(|e| GitError::Internal(e.to_string()))?; + let new_tree = new_obj.peel_to_tree().map_err(|e| GitError::Internal(e.to_string()))?; let diff = self .repo() @@ -221,14 +222,11 @@ impl GitDomain { .to_oid() .map_err(|_| GitError::InvalidOid(new_tree.to_string()))?; - let old_tree = self - .repo() - .find_tree(old_oid) - .map_err(|e| GitError::Internal(e.to_string()))?; - let new_tree = self - .repo() - .find_tree(new_oid) - .map_err(|e| GitError::Internal(e.to_string()))?; + let old_obj = self.repo().find_object(old_oid, None).map_err(|e| GitError::Internal(e.to_string()))?; + let new_obj = self.repo().find_object(new_oid, None).map_err(|e| GitError::Internal(e.to_string()))?; + + let old_tree = old_obj.peel_to_tree().map_err(|e| GitError::Internal(e.to_string()))?; + let new_tree = new_obj.peel_to_tree().map_err(|e| GitError::Internal(e.to_string()))?; let diff = self .repo() diff --git a/libs/git/hook/embed.rs b/libs/git/hook/embed.rs new file mode 100644 index 0000000..b76f7d8 --- /dev/null +++ b/libs/git/hook/embed.rs @@ -0,0 +1,8 @@ +use models::TagEmbedInput; + +/// Trait for tag embedding — implemented by agent's EmbedService. +/// Defined here to avoid git → agent dependency. +#[async_trait::async_trait] +pub trait TagEmbedder: Send + Sync { + async fn embed_tags_batch(&self, tags: Vec) -> Result<(), Box>; +} diff --git a/libs/git/hook/mod.rs b/libs/git/hook/mod.rs index 1ec9994..89d4d2d 100644 --- a/libs/git/hook/mod.rs +++ b/libs/git/hook/mod.rs @@ -1,44 +1,20 @@ +use std::sync::Arc; + use config::AppConfig; use db::cache::AppCache; use db::database::AppDatabase; use deadpool_redis::cluster::Pool as RedisPool; use tokio_util::sync::CancellationToken; +pub mod embed; pub mod pool; pub mod sync; pub mod webhook_dispatch; +pub use embed::TagEmbedder; pub use pool::{HookWorker, PoolConfig, RedisConsumer}; pub use pool::types::{HookTask, TaskType}; -/// Helper to initialize an optional EmbedService from config (graceful degradation). -async fn init_embed_service(config: &AppConfig, db: &AppDatabase) -> Option { - match agent::new_embed_client(config).await { - Ok(client) => { - let model_name = config - .get_embed_model_name() - .unwrap_or_else(|_| "text-embedding-3-small".into()); - let dimensions = config - .get_embed_model_dimensions() - .unwrap_or(1536); - let svc = agent::embed::EmbedService::new( - client, - db.writer().clone(), - model_name, - dimensions, - ); - // Ensure the repo_tag collection exists - let _ = svc.ensure_collections().await; - tracing::info!("hook worker: EmbedService initialized for tag embedding"); - Some(svc) - } - Err(e) => { - tracing::warn!(error = %e, "hook worker: EmbedService not available — tag embedding disabled"); - None - } - } -} - /// Hook service that manages the Redis-backed task queue worker. /// Multiple gitserver pods can run concurrently — the worker acquires a /// per-repo Redis lock before processing each task. @@ -48,7 +24,7 @@ pub struct HookService { pub(crate) cache: AppCache, pub(crate) redis_pool: RedisPool, pub(crate) config: AppConfig, - pub(crate) embed_service: Option, + pub(crate) tag_embedder: Option>, } impl HookService { @@ -63,31 +39,25 @@ impl HookService { cache, redis_pool, config, - embed_service: None, + tag_embedder: None, } } - /// Set an externally-initialized EmbedService (e.g. from the web app). - pub fn with_embed_service(mut self, svc: agent::embed::EmbedService) -> Self { - self.embed_service = Some(svc); + /// Set a tag embedder (typically from the agent crate). + pub fn with_tag_embedder(mut self, embedder: Arc) -> Self { + self.tag_embedder = Some(embedder); self } /// Start the background worker and return a cancellation token. pub async fn start_worker(&self) -> CancellationToken { - // Auto-init embed_service if not set and config allows (standalone binaries) - let embed = match self.embed_service.clone() { - Some(svc) => Some(svc), - None => init_embed_service(&self.config, &self.db).await, - }; - let pool_config = PoolConfig::from_env(&self.config); pool::start_worker( self.db.clone(), self.cache.clone(), self.redis_pool.clone(), pool_config, - embed, + self.tag_embedder.clone(), ) } } diff --git a/libs/git/hook/pool/mod.rs b/libs/git/hook/pool/mod.rs index 4327870..07267de 100644 --- a/libs/git/hook/pool/mod.rs +++ b/libs/git/hook/pool/mod.rs @@ -6,6 +6,7 @@ pub use redis::RedisConsumer; pub use types::{HookTask, PoolConfig, TaskType}; pub use worker::HookWorker; +use crate::hook::embed::TagEmbedder; use db::cache::AppCache; use db::database::AppDatabase; use deadpool_redis::cluster::Pool as RedisPool; @@ -19,7 +20,7 @@ pub fn start_worker( cache: AppCache, redis_pool: RedisPool, config: PoolConfig, - embed_service: Option, + tag_embedder: Option>, ) -> CancellationToken { let consumer = RedisConsumer::new( redis_pool.clone(), @@ -29,7 +30,7 @@ pub fn start_worker( let http_client = Arc::new(reqwest::Client::new()); let max_retries = config.redis_max_retries as u32; - let worker = HookWorker::new(db, cache, consumer, http_client, max_retries, embed_service); + let worker = HookWorker::new(db, cache, consumer, http_client, max_retries, tag_embedder); let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); diff --git a/libs/git/hook/pool/worker.rs b/libs/git/hook/pool/worker.rs index f59d045..199067a 100644 --- a/libs/git/hook/pool/worker.rs +++ b/libs/git/hook/pool/worker.rs @@ -1,8 +1,8 @@ use crate::error::GitError; +use crate::hook::embed::TagEmbedder; use crate::hook::pool::redis::RedisConsumer; use crate::hook::pool::types::{HookTask, TaskType}; use crate::hook::sync::HookMetaDataSync; -use agent::TagEmbedInput; use db::cache::AppCache; use db::database::AppDatabase; use metrics::counter; @@ -27,7 +27,7 @@ pub struct HookWorker { consumer: RedisConsumer, http_client: Arc, max_retries: u32, - embed_service: Option, + tag_embedder: Option>, } impl HookWorker { @@ -37,7 +37,7 @@ impl HookWorker { consumer: RedisConsumer, http_client: Arc, max_retries: u32, - embed_service: Option, + tag_embedder: Option>, ) -> Self { Self { db, @@ -45,7 +45,7 @@ impl HookWorker { consumer, http_client, max_retries, - embed_service, + tag_embedder, } } @@ -339,9 +339,9 @@ impl HookWorker { counter!("hook_sync_tags_changed_total").increment(tag_changes); // Embed changed tags into Qdrant for semantic search (non-blocking, fire-and-forget) - if let Some(ref embed) = self.embed_service { + if let Some(ref embedder) = self.tag_embedder { if !changed_tag_names.is_empty() { - let es = embed.clone(); + let ed = embedder.clone(); let db = self.db.clone(); let repo_uuid = repo_uuid; let repo_name = repo_name.clone(); @@ -357,9 +357,9 @@ impl HookWorker { match tags { Ok(rows) => { let count = rows.len(); - let inputs: Vec = rows + let inputs: Vec = rows .into_iter() - .map(|t| TagEmbedInput { + .map(|t| models::TagEmbedInput { repo_id: repo_uuid.to_string(), repo_name: repo_name.clone(), project_id: project_id.clone(), @@ -368,7 +368,7 @@ impl HookWorker { }) .collect(); if !inputs.is_empty() { - if let Err(e) = es.embed_tags_batch(inputs).await { + if let Err(e) = ed.embed_tags_batch(inputs).await { tracing::warn!(error = %e, "failed to embed changed tags"); } else { tracing::debug!(count, "embedded changed tags into Qdrant"); diff --git a/libs/git/ssh/mod.rs b/libs/git/ssh/mod.rs index 81283a8..a7cae39 100644 --- a/libs/git/ssh/mod.rs +++ b/libs/git/ssh/mod.rs @@ -204,6 +204,7 @@ impl ReceiveSyncService { match nats_publish("queue.hook.sync".to_string(), payload).await { Ok(seq) => { tracing::info!(repo_id = %task.repo_uid, seq = seq, "hook task queued to NATS"); + metrics::counter!("hook_task_queued_total", "backend" => "nats").increment(1); return; } Err(e) => { @@ -242,6 +243,7 @@ impl ReceiveSyncService { task.repo_uid, e); } else { tracing::info!(repo_id = %task.repo_uid, "hook task queued to Redis"); + metrics::counter!("hook_task_queued_total", "backend" => "redis").increment(1); } } } diff --git a/libs/git/tree/query.rs b/libs/git/tree/query.rs index bac08f1..d5af326 100644 --- a/libs/git/tree/query.rs +++ b/libs/git/tree/query.rs @@ -7,32 +7,41 @@ use crate::tree::types::{TreeEntry, TreeInfo}; use crate::{GitDomain, GitError, GitResult}; impl GitDomain { - pub fn tree_get(&self, oid: &CommitOid) -> GitResult { + fn resolve_tree(&self, oid: &CommitOid) -> GitResult> { let oid = oid .to_oid() .map_err(|_| GitError::InvalidOid(oid.to_string()))?; - let tree = self + let obj = self .repo() - .find_tree(oid) + .find_object(oid, None) .map_err(|_| GitError::ObjectNotFound(oid.to_string()))?; + match obj.kind() { + Some(git2::ObjectType::Commit) => { + let commit = obj.as_commit().ok_or_else(|| { + GitError::Internal("object type mismatch: expected commit".into()) + })?; + self.repo() + .find_tree(commit.tree_id()) + .map_err(|e| GitError::Internal(e.to_string())) + } + Some(git2::ObjectType::Tree) => obj + .peel_to_tree() + .map_err(|e| GitError::Internal(e.to_string())), + _ => Err(GitError::InvalidOid(oid.to_string())), + } + } + + pub fn tree_get(&self, oid: &CommitOid) -> GitResult { + let tree = self.resolve_tree(oid)?; Ok(TreeInfo::from_git2(&tree)) } pub fn tree_exists(&self, oid: &CommitOid) -> bool { - oid.to_oid() - .ok() - .and_then(|oid| self.repo.find_tree(oid).ok()) - .is_some() + self.resolve_tree(oid).is_ok() } pub fn tree_entry(&self, oid: &CommitOid, index: usize) -> GitResult { - let oid = oid - .to_oid() - .map_err(|_| GitError::InvalidOid(oid.to_string()))?; - let tree = self - .repo() - .find_tree(oid) - .map_err(|_| GitError::ObjectNotFound(oid.to_string()))?; + let tree = self.resolve_tree(oid)?; let entry = tree .get(index) .ok_or_else(|| GitError::Internal("tree entry not found".to_string()))?; @@ -40,13 +49,7 @@ impl GitDomain { } pub fn tree_list(&self, oid: &CommitOid) -> GitResult> { - let oid = oid - .to_oid() - .map_err(|_| GitError::InvalidOid(oid.to_string()))?; - let tree = self - .repo() - .find_tree(oid) - .map_err(|_| GitError::ObjectNotFound(oid.to_string()))?; + let tree = self.resolve_tree(oid)?; let repo = self.repo(); let entries: Vec = tree .iter() @@ -61,13 +64,7 @@ impl GitDomain { } pub fn tree_entry_by_path(&self, tree_oid: &CommitOid, path: &str) -> GitResult { - let oid = tree_oid - .to_oid() - .map_err(|_| GitError::InvalidOid(tree_oid.to_string()))?; - let tree = self - .repo() - .find_tree(oid) - .map_err(|_| GitError::ObjectNotFound(tree_oid.to_string()))?; + let tree = self.resolve_tree(tree_oid)?; let entry = tree .get_path(Path::new(path)) .map_err(|e| GitError::Internal(format!("path '{}': {}", path, e)))?; diff --git a/libs/migrate/lib.rs b/libs/migrate/lib.rs index f2f23cc..9abfc75 100644 --- a/libs/migrate/lib.rs +++ b/libs/migrate/lib.rs @@ -6,6 +6,20 @@ pub mod m20260426_000001_add_thinking_content_to_room_message; pub mod m20260428_000001_backfill_content_tsv; pub mod m20260428_000002_default_use_exact_true; pub mod m20260503_000001_replace_room_member; +pub mod m20260505_000001_create_project_role_priority; +pub mod m20260508_000001_create_ai_conversation; +pub mod m20260508_000002_create_ai_message; +pub mod m20260508_000003_create_ai_message_fork; +pub mod m20260508_000004_create_ai_shared_conversation; +pub mod m20260508_000005_create_ai_token_usage; +pub mod m20260508_000006_extend_ai_conversation; +pub mod m20260508_000007_create_project_context_setting; +pub mod m20260509_000001_create_user_billing; +pub mod m20260509_000002_create_billing_error; +pub mod m20260509_000003_extend_project_billing; +pub mod m20260509_000004_add_message_versioning; +pub mod m20260509_000005_extend_ai_message_fork; +pub mod m20260509_000006_create_subscription; pub async fn execute_sql(manager: &SchemaManager<'_>, sql: &str) -> Result<(), DbErr> { for stmt in split_sql_statements(sql) { @@ -60,7 +74,14 @@ impl MigratorTrait for Migrator { Box::new(m20250628_000011_create_user_ssh_key::Migration), Box::new(m20250628_000012_create_user_token::Migration), Box::new(m20250628_000004_create_user_activity_log::Migration), - // Project tables (CREATE first, then ALTER after workspace is created) + // Workspace tables + Box::new(m20260411_000001_create_workspace::Migration), + Box::new(m20260411_000002_create_workspace_membership::Migration), + Box::new(m20260411_000003_add_workspace_id_to_project::Migration), + Box::new(m20260411_000004_add_invite_token_to_workspace_membership::Migration), + Box::new(m20260412_000001_create_workspace_billing::Migration), + Box::new(m20260412_000002_create_workspace_billing_history::Migration), + // Project tables Box::new(m20250628_000013_create_project::Migration), Box::new(m20250628_000014_create_project_access_log::Migration), Box::new(m20250628_000015_create_project_audit_log::Migration), @@ -76,13 +97,7 @@ impl MigratorTrait for Migrator { Box::new(m20250628_000025_create_project_member_join_settings::Migration), Box::new(m20250628_000026_create_project_members::Migration), Box::new(m20250628_000027_create_project_watch::Migration), - // Workspace tables (must come after project tables so ALTER project works) - Box::new(m20260411_000001_create_workspace::Migration), - Box::new(m20260411_000002_create_workspace_membership::Migration), - Box::new(m20260411_000003_add_workspace_id_to_project::Migration), - Box::new(m20260411_000004_add_invite_token_to_workspace_membership::Migration), - Box::new(m20260412_000001_create_workspace_billing::Migration), - Box::new(m20260412_000002_create_workspace_billing_history::Migration), + // Skill tables Box::new(m20260412_000003_create_project_skill::Migration), Box::new(m20260413_000001_add_skill_commit_blob::Migration), Box::new(m20260414_000001_create_agent_task::Migration), @@ -97,6 +112,19 @@ impl MigratorTrait for Migrator { Box::new(m20260428_000001_backfill_content_tsv::Migration), Box::new(m20260428_000002_default_use_exact_true::Migration), Box::new(m20260503_000001_replace_room_member::Migration), + Box::new(m20260505_000001_create_project_role_priority::Migration), + Box::new(m20260508_000001_create_ai_conversation::Migration), + Box::new(m20260508_000002_create_ai_message::Migration), + Box::new(m20260508_000003_create_ai_message_fork::Migration), + Box::new(m20260508_000004_create_ai_shared_conversation::Migration), + Box::new(m20260508_000005_create_ai_token_usage::Migration), + Box::new(m20260508_000006_extend_ai_conversation::Migration), + Box::new(m20260509_000001_create_user_billing::Migration), + Box::new(m20260509_000002_create_billing_error::Migration), + Box::new(m20260509_000003_extend_project_billing::Migration), + Box::new(m20260509_000004_add_message_versioning::Migration), + Box::new(m20260509_000005_extend_ai_message_fork::Migration), + Box::new(m20260509_000006_create_subscription::Migration), // Repo tables Box::new(m20250628_000028_create_repo::Migration), Box::new(m20250628_000029_create_repo_branch::Migration), @@ -257,12 +285,6 @@ pub mod m20250628_000083_add_pr_review_request; pub mod m20260407_000001_extend_repo_branch_protect; pub mod m20260407_000002_create_project_board; pub mod m20260407_000003_add_repo_ai_code_review; -pub mod m20260411_000001_create_workspace; -pub mod m20260411_000002_create_workspace_membership; -pub mod m20260411_000003_add_workspace_id_to_project; -pub mod m20260411_000004_add_invite_token_to_workspace_membership; -pub mod m20260412_000001_create_workspace_billing; -pub mod m20260412_000002_create_workspace_billing_history; pub mod m20260412_000003_create_project_skill; pub mod m20260413_000001_add_skill_commit_blob; pub mod m20260414_000001_create_agent_task; @@ -271,3 +293,9 @@ pub mod m20260416_000001_add_retry_count_to_agent_task; pub mod m20260417_000001_add_stream_to_room_ai; pub mod m20260420_000001_create_room_attachment; pub mod m20260420_000002_add_push_subscription; +pub mod m20260411_000001_create_workspace; +pub mod m20260411_000002_create_workspace_membership; +pub mod m20260411_000003_add_workspace_id_to_project; +pub mod m20260411_000004_add_invite_token_to_workspace_membership; +pub mod m20260412_000001_create_workspace_billing; +pub mod m20260412_000002_create_workspace_billing_history; diff --git a/libs/migrate/m20260505_000001_create_project_role_priority.rs b/libs/migrate/m20260505_000001_create_project_role_priority.rs new file mode 100644 index 0000000..a8e2ae9 --- /dev/null +++ b/libs/migrate/m20260505_000001_create_project_role_priority.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: create project_role_priority table + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260505_000001_create_project_role_priority" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260505_000001_create_project_role_priority.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS project_role_priority;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000001_create_ai_conversation.rs b/libs/migrate/m20260508_000001_create_ai_conversation.rs new file mode 100644 index 0000000..4ccdd87 --- /dev/null +++ b/libs/migrate/m20260508_000001_create_ai_conversation.rs @@ -0,0 +1,23 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000001_create_ai_conversation.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS ai_conversation;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000002_create_ai_message.rs b/libs/migrate/m20260508_000002_create_ai_message.rs new file mode 100644 index 0000000..d5c7698 --- /dev/null +++ b/libs/migrate/m20260508_000002_create_ai_message.rs @@ -0,0 +1,23 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000002_create_ai_message.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS ai_message;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000003_create_ai_message_fork.rs b/libs/migrate/m20260508_000003_create_ai_message_fork.rs new file mode 100644 index 0000000..b0b4f16 --- /dev/null +++ b/libs/migrate/m20260508_000003_create_ai_message_fork.rs @@ -0,0 +1,23 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000003_create_ai_message_fork.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS ai_message_fork;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000004_create_ai_shared_conversation.rs b/libs/migrate/m20260508_000004_create_ai_shared_conversation.rs new file mode 100644 index 0000000..748699f --- /dev/null +++ b/libs/migrate/m20260508_000004_create_ai_shared_conversation.rs @@ -0,0 +1,23 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000004_create_ai_shared_conversation.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS ai_shared_conversation;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000005_create_ai_token_usage.rs b/libs/migrate/m20260508_000005_create_ai_token_usage.rs new file mode 100644 index 0000000..d7da876 --- /dev/null +++ b/libs/migrate/m20260508_000005_create_ai_token_usage.rs @@ -0,0 +1,23 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000005_create_ai_token_usage.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS ai_token_usage;".to_string(), + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260508_000006_extend_ai_conversation.rs b/libs/migrate/m20260508_000006_extend_ai_conversation.rs new file mode 100644 index 0000000..7f9755a --- /dev/null +++ b/libs/migrate/m20260508_000006_extend_ai_conversation.rs @@ -0,0 +1,31 @@ +//! SeaORM migration: add access_visibility, can_ask, project_uid, model_uid, model_name to ai_conversation + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260508_000006_extend_ai_conversation" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000006_extend_ai_conversation.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + super::execute_sql( + manager, + "ALTER TABLE ai_conversation DROP COLUMN IF EXISTS access_visibility; + ALTER TABLE ai_conversation DROP COLUMN IF EXISTS can_ask; + ALTER TABLE ai_conversation DROP COLUMN IF EXISTS project_uid; + ALTER TABLE ai_conversation DROP COLUMN IF EXISTS model_uid; + ALTER TABLE ai_conversation DROP COLUMN IF EXISTS model_name;", + ) + .await + } +} diff --git a/libs/migrate/m20260508_000007_create_project_context_setting.rs b/libs/migrate/m20260508_000007_create_project_context_setting.rs new file mode 100644 index 0000000..5f34630 --- /dev/null +++ b/libs/migrate/m20260508_000007_create_project_context_setting.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: create project_context_setting table + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260508_000007_create_project_context_setting" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260508_000007_create_project_context_setting.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS project_context_setting;", + )) + .await?; + Ok(()) + } +} diff --git a/libs/migrate/m20260509_000001_create_user_billing.rs b/libs/migrate/m20260509_000001_create_user_billing.rs new file mode 100644 index 0000000..ec44685 --- /dev/null +++ b/libs/migrate/m20260509_000001_create_user_billing.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: create user_billing table + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260509_000001_create_user_billing" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000001_create_user_billing.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS user_billing;", + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/m20260509_000002_create_billing_error.rs b/libs/migrate/m20260509_000002_create_billing_error.rs new file mode 100644 index 0000000..320235c --- /dev/null +++ b/libs/migrate/m20260509_000002_create_billing_error.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: create billing_error table + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260509_000002_create_billing_error" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000002_create_billing_error.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS billing_error;", + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/m20260509_000003_extend_project_billing.rs b/libs/migrate/m20260509_000003_extend_project_billing.rs new file mode 100644 index 0000000..abf2607 --- /dev/null +++ b/libs/migrate/m20260509_000003_extend_project_billing.rs @@ -0,0 +1,65 @@ +//! SeaORM migration: extend project_billing with pro/quota fields + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260509_000003_extend_project_billing" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000003_extend_project_billing.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS initial_credit_granted;", + )) + .await?; + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS is_pro;", + )) + .await?; + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS monthly_quota;", + )) + .await?; + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS month_used;", + )) + .await?; + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS cycle_start;", + )) + .await?; + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "ALTER TABLE project_billing DROP COLUMN IF EXISTS cycle_end;", + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/m20260509_000004_add_message_versioning.rs b/libs/migrate/m20260509_000004_add_message_versioning.rs new file mode 100644 index 0000000..80e9b5f --- /dev/null +++ b/libs/migrate/m20260509_000004_add_message_versioning.rs @@ -0,0 +1,24 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000004_add_message_versioning.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP INDEX IF EXISTS idx_ai_msg_version_latest; DROP INDEX IF EXISTS idx_ai_msg_version_group; ALTER TABLE ai_message DROP COLUMN IF EXISTS is_latest; ALTER TABLE ai_message DROP COLUMN IF EXISTS version_number; ALTER TABLE ai_message DROP COLUMN IF EXISTS version_group_id;" + .to_string(), + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/m20260509_000005_extend_ai_message_fork.rs b/libs/migrate/m20260509_000005_extend_ai_message_fork.rs new file mode 100644 index 0000000..9d49e44 --- /dev/null +++ b/libs/migrate/m20260509_000005_extend_ai_message_fork.rs @@ -0,0 +1,24 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000005_extend_ai_message_fork.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP INDEX IF EXISTS idx_ai_fork_conv; ALTER TABLE ai_message_fork DROP COLUMN IF EXISTS conversation_id;" + .to_string(), + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/m20260509_000006_create_subscription.rs b/libs/migrate/m20260509_000006_create_subscription.rs new file mode 100644 index 0000000..a9ea4c6 --- /dev/null +++ b/libs/migrate/m20260509_000006_create_subscription.rs @@ -0,0 +1,30 @@ +//! SeaORM migration: create subscription table + +use sea_orm_migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20260509_000006_create_subscription" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let sql = include_str!("sql/m20260509_000006_create_subscription.sql"); + super::execute_sql(manager, sql).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_raw(sea_orm::Statement::from_string( + sea_orm::DbBackend::Postgres, + "DROP TABLE IF EXISTS subscription;", + )) + .await?; + Ok(()) + } +} \ No newline at end of file diff --git a/libs/migrate/sql/m20260505_000001_create_project_role_priority.sql b/libs/migrate/sql/m20260505_000001_create_project_role_priority.sql new file mode 100644 index 0000000..a5f93f0 --- /dev/null +++ b/libs/migrate/sql/m20260505_000001_create_project_role_priority.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS project_role_priority ( + id BIGSERIAL PRIMARY KEY, + project_uuid UUID NOT NULL, + role_key VARCHAR(64) NOT NULL, + display_name VARCHAR(128) NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + color VARCHAR(32), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(project_uuid, role_key) +); + +CREATE INDEX idx_project_role_priority_project ON project_role_priority(project_uuid); +CREATE INDEX idx_project_role_priority_priority ON project_role_priority(project_uuid, priority); diff --git a/libs/migrate/sql/m20260508_000001_create_ai_conversation.sql b/libs/migrate/sql/m20260508_000001_create_ai_conversation.sql new file mode 100644 index 0000000..e5623a1 --- /dev/null +++ b/libs/migrate/sql/m20260508_000001_create_ai_conversation.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS ai_conversation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + project_id UUID, + scope VARCHAR(16) NOT NULL, + title VARCHAR(512), + model VARCHAR(128) NOT NULL DEFAULT 'gpt-4', + model_config JSONB, + status VARCHAR(32) NOT NULL DEFAULT 'active', + root_message_id UUID, + fork_count INT NOT NULL DEFAULT 0, + is_shared BOOLEAN NOT NULL DEFAULT false, + message_count INT NOT NULL DEFAULT 0, + token_usage_total INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_conv_user_id ON ai_conversation (user_id); +CREATE INDEX IF NOT EXISTS idx_ai_conv_project_id ON ai_conversation (project_id); +CREATE INDEX IF NOT EXISTS idx_ai_conv_scope ON ai_conversation (scope); +CREATE INDEX IF NOT EXISTS idx_ai_conv_user_created ON ai_conversation (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ai_conv_project_created ON ai_conversation (project_id, created_at DESC); + +ALTER TABLE ai_conversation + ADD CONSTRAINT fk_ai_conv_project + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE; diff --git a/libs/migrate/sql/m20260508_000002_create_ai_message.sql b/libs/migrate/sql/m20260508_000002_create_ai_message.sql new file mode 100644 index 0000000..0050190 --- /dev/null +++ b/libs/migrate/sql/m20260508_000002_create_ai_message.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS ai_message ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL, + parent_message_id UUID, + role VARCHAR(16) NOT NULL, + content JSONB NOT NULL, + model VARCHAR(128), + is_fork_origin BOOLEAN NOT NULL DEFAULT false, + stop_reason VARCHAR(32), + input_tokens INT, + output_tokens INT, + latency_ms INT, + metadata JSONB, + room_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_msg_conv ON ai_message (conversation_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ai_msg_parent ON ai_message (parent_message_id); + +ALTER TABLE ai_message + ADD CONSTRAINT fk_ai_msg_conv + FOREIGN KEY (conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE; + +ALTER TABLE ai_message + ADD CONSTRAINT fk_ai_msg_parent + FOREIGN KEY (parent_message_id) REFERENCES ai_message(id) ON DELETE SET NULL; diff --git a/libs/migrate/sql/m20260508_000003_create_ai_message_fork.sql b/libs/migrate/sql/m20260508_000003_create_ai_message_fork.sql new file mode 100644 index 0000000..27d88b8 --- /dev/null +++ b/libs/migrate/sql/m20260508_000003_create_ai_message_fork.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS ai_message_fork ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_message_id UUID NOT NULL, + fork_message_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_fork_source ON ai_message_fork (source_message_id); +CREATE INDEX IF NOT EXISTS idx_ai_fork_fork ON ai_message_fork (fork_message_id); + +ALTER TABLE ai_message_fork + ADD CONSTRAINT fk_ai_fork_source + FOREIGN KEY (source_message_id) REFERENCES ai_message(id) ON DELETE CASCADE; + +ALTER TABLE ai_message_fork + ADD CONSTRAINT fk_ai_fork_fork + FOREIGN KEY (fork_message_id) REFERENCES ai_message(id) ON DELETE CASCADE; diff --git a/libs/migrate/sql/m20260508_000004_create_ai_shared_conversation.sql b/libs/migrate/sql/m20260508_000004_create_ai_shared_conversation.sql new file mode 100644 index 0000000..a4e01ac --- /dev/null +++ b/libs/migrate/sql/m20260508_000004_create_ai_shared_conversation.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS ai_shared_conversation ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID NOT NULL, + share_token VARCHAR(128) UNIQUE NOT NULL, + created_by UUID NOT NULL, + view_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_ai_share_conv ON ai_shared_conversation (conversation_id); +CREATE INDEX IF NOT EXISTS idx_ai_share_token ON ai_shared_conversation (share_token); + +ALTER TABLE ai_shared_conversation + ADD CONSTRAINT fk_ai_share_conv + FOREIGN KEY (conversation_id) REFERENCES ai_conversation(id) ON DELETE CASCADE; diff --git a/libs/migrate/sql/m20260508_000005_create_ai_token_usage.sql b/libs/migrate/sql/m20260508_000005_create_ai_token_usage.sql new file mode 100644 index 0000000..59729d7 --- /dev/null +++ b/libs/migrate/sql/m20260508_000005_create_ai_token_usage.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS ai_token_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + conversation_id UUID, + model VARCHAR(128) NOT NULL, + input_tokens INT NOT NULL, + output_tokens INT NOT NULL, + cost_usd NUMERIC(10, 6), + recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_token_user ON ai_token_usage (user_id); +CREATE INDEX IF NOT EXISTS idx_ai_token_conv ON ai_token_usage (conversation_id); +CREATE INDEX IF NOT EXISTS idx_ai_token_recorded ON ai_token_usage (recorded_at); diff --git a/libs/migrate/sql/m20260508_000006_extend_ai_conversation.sql b/libs/migrate/sql/m20260508_000006_extend_ai_conversation.sql new file mode 100644 index 0000000..a20f0d3 --- /dev/null +++ b/libs/migrate/sql/m20260508_000006_extend_ai_conversation.sql @@ -0,0 +1,8 @@ +ALTER TABLE ai_conversation ADD COLUMN IF NOT EXISTS access_visibility VARCHAR(32) NOT NULL DEFAULT 'owner'; +ALTER TABLE ai_conversation ADD COLUMN IF NOT EXISTS can_ask VARCHAR(32) NOT NULL DEFAULT 'owner'; +ALTER TABLE ai_conversation ADD COLUMN IF NOT EXISTS project_uid INT; +ALTER TABLE ai_conversation ADD COLUMN IF NOT EXISTS model_uid UUID; +ALTER TABLE ai_conversation ADD COLUMN IF NOT EXISTS model_name VARCHAR(256); + +CREATE INDEX IF NOT EXISTS idx_ai_conv_access_vis ON ai_conversation (access_visibility); +CREATE INDEX IF NOT EXISTS idx_ai_conv_project_uid ON ai_conversation (project_id, project_uid) WHERE project_uid IS NOT NULL; diff --git a/libs/migrate/sql/m20260508_000007_create_project_context_setting.sql b/libs/migrate/sql/m20260508_000007_create_project_context_setting.sql new file mode 100644 index 0000000..938001d --- /dev/null +++ b/libs/migrate/sql/m20260508_000007_create_project_context_setting.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS project_context_setting ( + project_id UUID PRIMARY KEY, + context_window_tokens INT NOT NULL DEFAULT 128000, + compaction_threshold FLOAT NOT NULL DEFAULT 0.8, + compaction_max_summary_ratio FLOAT NOT NULL DEFAULT 0.2, + rag_enabled BOOLEAN NOT NULL DEFAULT true, + rag_cross_session BOOLEAN NOT NULL DEFAULT true, + rag_max_results INT NOT NULL DEFAULT 10, + rag_min_score FLOAT NOT NULL DEFAULT 0.5, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_proj_ctx_rag_enabled ON project_context_setting (rag_enabled); + +ALTER TABLE project_context_setting + ADD CONSTRAINT fk_proj_ctx_project + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE; diff --git a/libs/migrate/sql/m20260509_000001_create_user_billing.sql b/libs/migrate/sql/m20260509_000001_create_user_billing.sql new file mode 100644 index 0000000..9cfe435 --- /dev/null +++ b/libs/migrate/sql/m20260509_000001_create_user_billing.sql @@ -0,0 +1,14 @@ +CREATE TABLE user_billing ( + user_uuid UUID PRIMARY KEY, + balance DECIMAL(20, 4) DEFAULT 10.0000 NOT NULL, + currency VARCHAR(10) DEFAULT 'USD' NOT NULL, + is_pro BOOLEAN DEFAULT FALSE NOT NULL, + monthly_quota DECIMAL(20, 4) DEFAULT 0 NOT NULL, + month_used DECIMAL(20, 4) DEFAULT 0 NOT NULL, + cycle_start TIMESTAMPTZ, + cycle_end TIMESTAMPTZ, + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_user_billing_is_pro ON user_billing (is_pro) WHERE is_pro = TRUE; \ No newline at end of file diff --git a/libs/migrate/sql/m20260509_000002_create_billing_error.sql b/libs/migrate/sql/m20260509_000002_create_billing_error.sql new file mode 100644 index 0000000..713460e --- /dev/null +++ b/libs/migrate/sql/m20260509_000002_create_billing_error.sql @@ -0,0 +1,13 @@ +CREATE TABLE billing_error ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + scope VARCHAR(20) NOT NULL, + scope_id UUID NOT NULL, + error_type VARCHAR(50) NOT NULL, + message TEXT NOT NULL, + details JSONB, + resolved BOOLEAN DEFAULT FALSE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_billing_error_scope ON billing_error (scope, scope_id); +CREATE INDEX idx_billing_error_resolved ON billing_error (resolved) WHERE resolved = FALSE; \ No newline at end of file diff --git a/libs/migrate/sql/m20260509_000003_extend_project_billing.sql b/libs/migrate/sql/m20260509_000003_extend_project_billing.sql new file mode 100644 index 0000000..9e8eb3a --- /dev/null +++ b/libs/migrate/sql/m20260509_000003_extend_project_billing.sql @@ -0,0 +1,6 @@ +ALTER TABLE project_billing ADD COLUMN initial_credit_granted BOOLEAN DEFAULT FALSE NOT NULL; +ALTER TABLE project_billing ADD COLUMN is_pro BOOLEAN DEFAULT FALSE NOT NULL; +ALTER TABLE project_billing ADD COLUMN monthly_quota DECIMAL(20, 4) DEFAULT 0 NOT NULL; +ALTER TABLE project_billing ADD COLUMN month_used DECIMAL(20, 4) DEFAULT 0 NOT NULL; +ALTER TABLE project_billing ADD COLUMN cycle_start TIMESTAMPTZ; +ALTER TABLE project_billing ADD COLUMN cycle_end TIMESTAMPTZ; \ No newline at end of file diff --git a/libs/migrate/sql/m20260509_000004_add_message_versioning.sql b/libs/migrate/sql/m20260509_000004_add_message_versioning.sql new file mode 100644 index 0000000..a8ab364 --- /dev/null +++ b/libs/migrate/sql/m20260509_000004_add_message_versioning.sql @@ -0,0 +1,15 @@ +-- Add version management columns to ai_message for edit/regenerate with version tracking +-- version_group_id: groups all versions of the same message (original + edits) +-- version_number: sequential version within the group (1 = original, 2 = first edit, etc.) +-- is_latest: marks the current active version in the group + +ALTER TABLE ai_message + ADD COLUMN IF NOT EXISTS version_group_id UUID, + ADD COLUMN IF NOT EXISTS version_number INT NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS is_latest BOOLEAN NOT NULL DEFAULT true; + +CREATE INDEX IF NOT EXISTS idx_ai_msg_version_group ON ai_message (version_group_id, version_number); +CREATE INDEX IF NOT EXISTS idx_ai_msg_version_latest ON ai_message (version_group_id, is_latest) WHERE is_latest = true; + +-- Backfill: set version_group_id = id for existing messages (each existing message is its own group v1) +UPDATE ai_message SET version_group_id = id WHERE version_group_id IS NULL; \ No newline at end of file diff --git a/libs/migrate/sql/m20260509_000005_extend_ai_message_fork.sql b/libs/migrate/sql/m20260509_000005_extend_ai_message_fork.sql new file mode 100644 index 0000000..539bbf3 --- /dev/null +++ b/libs/migrate/sql/m20260509_000005_extend_ai_message_fork.sql @@ -0,0 +1,11 @@ +-- Add conversation_id to ai_message_fork for efficient querying of forks within a conversation +ALTER TABLE ai_message_fork + ADD COLUMN IF NOT EXISTS conversation_id UUID; + +CREATE INDEX IF NOT EXISTS idx_ai_fork_conv ON ai_message_fork (conversation_id); + +-- Backfill conversation_id from source message +UPDATE ai_message_fork f +SET conversation_id = m.conversation_id +FROM ai_message m +WHERE f.source_message_id = m.id AND f.conversation_id IS NULL; \ No newline at end of file diff --git a/libs/migrate/sql/m20260509_000006_create_subscription.sql b/libs/migrate/sql/m20260509_000006_create_subscription.sql new file mode 100644 index 0000000..63e48a3 --- /dev/null +++ b/libs/migrate/sql/m20260509_000006_create_subscription.sql @@ -0,0 +1,31 @@ +CREATE TABLE subscription ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- Who is subscribed: "user" or "project" + scope VARCHAR(20) NOT NULL, + scope_id UUID NOT NULL, + -- Subscription period + start_at TIMESTAMPTZ NOT NULL, + end_at TIMESTAMPTZ NOT NULL, + -- Payment info + order_id VARCHAR(100), + platform VARCHAR(50) NOT NULL, + plan_name VARCHAR(100), + -- Quotas + quota_6h DECIMAL(20, 4) DEFAULT 0 NOT NULL, + quota_6h_used DECIMAL(20, 4) DEFAULT 0 NOT NULL, + quota_6h_reset_at TIMESTAMPTZ, + quota_weekly DECIMAL(20, 4) DEFAULT 0 NOT NULL, + quota_weekly_used DECIMAL(20, 4) DEFAULT 0 NOT NULL, + quota_weekly_reset_at TIMESTAMPTZ, + quota_monthly DECIMAL(20, 4) DEFAULT 0 NOT NULL, + quota_monthly_used DECIMAL(20, 4) DEFAULT 0 NOT NULL, + -- Status + is_active BOOLEAN DEFAULT TRUE NOT NULL, + currency VARCHAR(10) DEFAULT 'USD' NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_subscription_scope ON subscription (scope, scope_id); +CREATE INDEX idx_subscription_active ON subscription (is_active) WHERE is_active = TRUE; +CREATE INDEX idx_subscription_end_at ON subscription (end_at) WHERE is_active = TRUE; \ No newline at end of file diff --git a/libs/models/ai/ai_conversation.rs b/libs/models/ai/ai_conversation.rs new file mode 100644 index 0000000..d5742e8 --- /dev/null +++ b/libs/models/ai/ai_conversation.rs @@ -0,0 +1,41 @@ +use crate::{DateTimeUtc, ProjectId, UserId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "ai_conversation")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub user_id: UserId, + pub project_id: Option, + pub scope: String, + pub title: Option, + pub model: String, + pub model_config: Option, + pub status: String, + pub root_message_id: Option, + pub fork_count: i32, + pub is_shared: bool, + pub message_count: i32, + pub token_usage_total: Option, + /// Who can see this chat: "owner" | "admin" | "member" + pub access_visibility: String, + /// Who can send messages: "owner" | "admin" | "member" + pub can_ask: String, + /// Project-unique sequential number for this chat + pub project_uid: Option, + /// AI model UUID selected for this chat + pub model_uid: Option, + /// AI model display name (e.g. "Claude Sonnet 4") + pub model_name: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/ai/ai_message.rs b/libs/models/ai/ai_message.rs new file mode 100644 index 0000000..b02dda9 --- /dev/null +++ b/libs/models/ai/ai_message.rs @@ -0,0 +1,37 @@ +use crate::{DateTimeUtc, Uuid}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "ai_message")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub conversation_id: Uuid, + pub parent_message_id: Option, + pub role: String, + pub content: Json, + pub model: Option, + pub is_fork_origin: bool, + pub stop_reason: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub latency_ms: Option, + pub metadata: Option, + pub room_id: Option, + /// Groups all versions of the same message (original + edits). + /// For new messages, this equals the message id itself. + pub version_group_id: Option, + /// Sequential version number within the group (1 = original, 2 = first edit, etc.) + pub version_number: i32, + /// Whether this is the current active version in the group. + pub is_latest: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/ai/ai_message_fork.rs b/libs/models/ai/ai_message_fork.rs new file mode 100644 index 0000000..1f867b0 --- /dev/null +++ b/libs/models/ai/ai_message_fork.rs @@ -0,0 +1,22 @@ +use crate::DateTimeUtc; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive( + Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "ai_message_fork")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub conversation_id: Option, + pub source_message_id: Uuid, + pub fork_message_id: Uuid, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/ai/ai_shared_conversation.rs b/libs/models/ai/ai_shared_conversation.rs new file mode 100644 index 0000000..f4d0244 --- /dev/null +++ b/libs/models/ai/ai_shared_conversation.rs @@ -0,0 +1,23 @@ +use crate::{DateTimeUtc, UserId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "ai_shared_conversation")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub conversation_id: Uuid, + pub share_token: String, + pub created_by: UserId, + pub view_count: i32, + pub created_at: DateTimeUtc, + pub expires_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/ai/ai_token_usage.rs b/libs/models/ai/ai_token_usage.rs new file mode 100644 index 0000000..a46f201 --- /dev/null +++ b/libs/models/ai/ai_token_usage.rs @@ -0,0 +1,24 @@ +use crate::{DateTimeUtc, UserId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive( + Clone, Debug, PartialEq, Eq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "ai_token_usage")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub user_id: UserId, + pub conversation_id: Option, + pub model: String, + pub input_tokens: i32, + pub output_tokens: i32, + pub cost_usd: Option, + pub recorded_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/ai/billing_error.rs b/libs/models/ai/billing_error.rs new file mode 100644 index 0000000..a21342e --- /dev/null +++ b/libs/models/ai/billing_error.rs @@ -0,0 +1,33 @@ +use crate::DateTimeUtc; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Persisted billing error that requires user attention. +/// Created when an AI request fails due to insufficient balance or quota. +/// Front-end reads unresolved errors to show warning banners. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "billing_error")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + /// "user" or "project" + #[sea_orm(column_type = "Text")] + pub scope: String, + /// user_uuid or project_uuid + pub scope_id: Uuid, + /// "insufficient_balance" or "over_quota" + #[sea_orm(column_type = "Text")] + pub error_type: String, + #[sea_orm(column_type = "Text")] + pub message: String, + pub details: Option, + #[sea_orm(default_value = false)] + pub resolved: bool, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/libs/models/ai/mod.rs b/libs/models/ai/mod.rs index d327084..de0b4c9 100644 --- a/libs/models/ai/mod.rs +++ b/libs/models/ai/mod.rs @@ -37,10 +37,21 @@ impl std::str::FromStr for ToolCallStatus { } } -pub use ai_session::Entity as AiSession; -pub use ai_tool_auth::Entity as AiToolAuth; -pub use ai_tool_call::Entity as AiToolCall; +pub use ai_conversation::Entity as AiConversation; +pub use ai_message::Entity as AiMessage; +pub use ai_message_fork::Entity as AiMessageFork; +pub use ai_shared_conversation::Entity as AiSharedConversation; +pub use ai_token_usage::Entity as AiTokenUsage; +pub use billing_error::Entity as BillingError; +pub use subscription::Entity as Subscription; +pub mod ai_conversation; +pub mod ai_message; +pub mod ai_message_fork; pub mod ai_session; +pub mod ai_shared_conversation; pub mod ai_tool_auth; pub mod ai_tool_call; +pub mod ai_token_usage; +pub mod billing_error; +pub mod subscription; diff --git a/libs/models/ai/subscription.rs b/libs/models/ai/subscription.rs new file mode 100644 index 0000000..abf1897 --- /dev/null +++ b/libs/models/ai/subscription.rs @@ -0,0 +1,63 @@ +use crate::DateTimeUtc; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Subscription record — tracks paid plans for users or projects. +/// Each subscription defines quota limits (6h, weekly, monthly) and payment metadata. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "subscription")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + /// Who is subscribed: "user" or "project" + pub scope: String, + /// The UUID of the user or project + #[sea_orm(column_name = "scope_id")] + pub scope_id: Uuid, + /// Subscription start time + pub start_at: DateTimeUtc, + /// Subscription end (expiry) time + pub end_at: DateTimeUtc, + /// External order / transaction ID from payment platform + #[sea_orm(column_type = "Text", nullable)] + pub order_id: Option, + /// Payment platform identifier (e.g. "stripe", "alipay", "wechat") + pub platform: String, + /// Human-readable plan name (e.g. "Pro Monthly", "Team Annual") + #[sea_orm(column_type = "Text", nullable)] + pub plan_name: Option, + /// 6-hour quota limit (auto-reset every 6 hours) + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_6h: Decimal, + /// 6-hour quota used so far in current window + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_6h_used: Decimal, + /// Next 6-hour reset timestamp + pub quota_6h_reset_at: Option, + /// Weekly quota limit + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_weekly: Decimal, + /// Weekly quota used so far + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_weekly_used: Decimal, + /// Next weekly reset timestamp + pub quota_weekly_reset_at: Option, + /// Monthly quota limit + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_monthly: Decimal, + /// Monthly quota used so far + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub quota_monthly_used: Decimal, + /// Whether subscription is currently active + #[sea_orm(default_value = false)] + pub is_active: bool, + #[sea_orm(column_type = "Text")] + pub currency: String, + pub updated_at: DateTimeUtc, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/libs/models/lib.rs b/libs/models/lib.rs index 3244428..af062fb 100644 --- a/libs/models/lib.rs +++ b/libs/models/lib.rs @@ -10,13 +10,8 @@ pub mod repos; pub mod rooms; pub mod system; pub mod users; -pub mod workspaces; - pub use chrono::Utc as UtcClock; -pub use workspaces::{Workspace, WorkspaceRole}; -pub type WorkspaceId = Uuid; - pub use agent_task::{AgentType, TaskStatus}; pub type AgentTaskId = i64; pub type UserId = Uuid; @@ -33,6 +28,18 @@ pub type RoomCategoryId = Uuid; pub type MessageId = Uuid; pub type RoomThreadId = Uuid; pub type AiSessionId = Uuid; +pub type AiConversationId = Uuid; +pub type AiMessageId = Uuid; pub type Seq = i64; pub type LabelId = i64; pub type DateTimeUtc = chrono::DateTime; + +/// Input for batch tag embedding — shared between git hooks and agent embed service. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct TagEmbedInput { + pub repo_id: String, + pub repo_name: String, + pub project_id: String, + pub name: String, + pub description: Option, +} diff --git a/libs/models/projects/mod.rs b/libs/models/projects/mod.rs index 76fee56..adda7e1 100644 --- a/libs/models/projects/mod.rs +++ b/libs/models/projects/mod.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; /// Project member role. Stored as `"owner"`, `"admin"`, or `"member"` in the database. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub enum MemberRole { Owner, Admin, @@ -33,6 +33,8 @@ impl std::str::FromStr for MemberRole { pub use project::Entity as Project; pub use project_access_log::Entity as ProjectAccessLog; +pub use project_context_setting::Entity as ProjectContextSetting; +pub mod project_context_setting; pub use project_activity::Entity as ProjectActivity; pub use project_audit_log::Entity as ProjectAuditLog; pub use project_billing::Entity as ProjectBilling; @@ -50,6 +52,7 @@ pub use project_member_join_answers::Entity as ProjectMemberJoinAnswers; pub use project_member_join_request::Entity as ProjectMemberJoinRequest; pub use project_member_join_settings::Entity as ProjectMemberJoinSettings; pub use project_members::Entity as ProjectMember; +pub use project_role_priority::Entity as ProjectRolePriority; pub use project_watch::Entity as ProjectWatch; pub mod project; @@ -70,4 +73,5 @@ pub mod project_member_join_answers; pub mod project_member_join_request; pub mod project_member_join_settings; pub mod project_members; +pub mod project_role_priority; pub mod project_watch; diff --git a/libs/models/projects/project.rs b/libs/models/projects/project.rs index a6371c4..1766fbb 100644 --- a/libs/models/projects/project.rs +++ b/libs/models/projects/project.rs @@ -1,4 +1,4 @@ -use crate::{DateTimeUtc, ProjectId, UserId, WorkspaceId}; +use crate::{DateTimeUtc, ProjectId, UserId}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -13,7 +13,6 @@ pub struct Model { pub description: Option, pub is_public: bool, pub created_by: UserId, - pub workspace_id: Option, pub created_at: DateTimeUtc, pub updated_at: DateTimeUtc, } diff --git a/libs/models/projects/project_billing.rs b/libs/models/projects/project_billing.rs index 3d3e5e2..e3df5f6 100644 --- a/libs/models/projects/project_billing.rs +++ b/libs/models/projects/project_billing.rs @@ -3,6 +3,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; /// Per-project billing account holding the current balance. +/// First project per user gets $20; subsequent projects get $0. #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] #[sea_orm(table_name = "project_billing")] pub struct Model { @@ -15,6 +16,18 @@ pub struct Model { pub currency: String, #[sea_orm(column_name = "user_uuid")] pub user: Option, + /// Whether the initial credit (first project $20 bonus) was granted. + #[sea_orm(default_value = false)] + pub initial_credit_granted: bool, + /// Whether this project belongs to a pro user (monthly quota applies). + #[sea_orm(default_value = false)] + pub is_pro: bool, + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub monthly_quota: Decimal, + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub month_used: Decimal, + pub cycle_start: Option, + pub cycle_end: Option, pub updated_at: DateTimeUtc, pub created_at: DateTimeUtc, } diff --git a/libs/models/projects/project_context_setting.rs b/libs/models/projects/project_context_setting.rs new file mode 100644 index 0000000..19fcfd6 --- /dev/null +++ b/libs/models/projects/project_context_setting.rs @@ -0,0 +1,38 @@ +use crate::{DateTimeUtc, ProjectId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Project-level context management settings. +/// Controls compaction (sliding window) and RAG behavior. +#[derive( + Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel, +)] +#[sea_orm(table_name = "project_context_setting")] +pub struct Model { + #[sea_orm(primary_key)] + pub project_id: ProjectId, + /// Context window size in tokens (e.g. 128000 for Claude). + /// When actual usage reaches this * compaction_threshold, trigger compaction. + pub context_window_tokens: i32, + /// Trigger compaction when usage exceeds this fraction of context_window_tokens. + /// Default 0.8 (80%). Valid range: 0.5 - 0.9. + pub compaction_threshold: f32, + /// Compressed summary must not exceed this fraction of context_window_tokens. + /// Default 0.2 (20%). Valid range: 0.05 - 0.3. + pub compaction_max_summary_ratio: f32, + /// Enable RAG for room conversations. + pub rag_enabled: bool, + /// RAG cross-session: room messages searchable across conversations. + pub rag_cross_session: bool, + /// Maximum RAG results to include in context. + pub rag_max_results: i32, + /// Minimum relevance score for RAG results (0.0 - 1.0). + pub rag_min_score: f32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/libs/models/projects/project_role_priority.rs b/libs/models/projects/project_role_priority.rs new file mode 100644 index 0000000..96354ee --- /dev/null +++ b/libs/models/projects/project_role_priority.rs @@ -0,0 +1,23 @@ +use crate::{DateTimeUtc, ProjectId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "project_role_priority")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i64, + #[sea_orm(column_name = "project_uuid")] + pub project: ProjectId, + pub role_key: String, + pub display_name: String, + pub priority: i32, + pub color: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/libs/models/rooms/room_notifications.rs b/libs/models/rooms/room_notifications.rs index a0162f1..4ec9e69 100644 --- a/libs/models/rooms/room_notifications.rs +++ b/libs/models/rooms/room_notifications.rs @@ -20,8 +20,6 @@ pub enum NotificationType { SystemAnnouncement, #[sea_orm(string_value = "project_invitation")] ProjectInvitation, - #[sea_orm(string_value = "workspace_invitation")] - WorkspaceInvitation, } impl std::fmt::Display for NotificationType { @@ -34,7 +32,6 @@ impl std::fmt::Display for NotificationType { NotificationType::RoomDeleted => "room_deleted", NotificationType::SystemAnnouncement => "system_announcement", NotificationType::ProjectInvitation => "project_invitation", - NotificationType::WorkspaceInvitation => "workspace_invitation", }; write!(f, "{}", s) } diff --git a/libs/models/users/mod.rs b/libs/models/users/mod.rs index 702594a..ee57e5a 100644 --- a/libs/models/users/mod.rs +++ b/libs/models/users/mod.rs @@ -10,6 +10,7 @@ pub use user_preferences::Entity as UserPreferences; pub use user_relation::Entity as UserRelation; pub use user_ssh_key::Entity as UserSshKey; pub use user_token::Entity as UserToken; +pub use user_billing::Entity as UserBilling; pub mod user; pub mod user_2fa; @@ -23,3 +24,4 @@ pub mod user_preferences; pub mod user_relation; pub mod user_ssh_key; pub mod user_token; +pub mod user_billing; diff --git a/libs/models/users/user_billing.rs b/libs/models/users/user_billing.rs new file mode 100644 index 0000000..44aadd2 --- /dev/null +++ b/libs/models/users/user_billing.rs @@ -0,0 +1,46 @@ +use crate::{DateTimeUtc, UserId}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// Per-user billing account. +/// Each user gets a default $10 balance on creation. +/// Pro users additionally have a monthly quota that resets each cycle. +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_billing")] +pub struct Model { + #[sea_orm(primary_key)] + #[sea_orm(column_name = "user_uuid")] + pub user: UserId, + #[sea_orm(column_type = "Decimal(Some((20, 4)))")] + pub balance: Decimal, + #[sea_orm(column_type = "Text", default_value = "USD")] + pub currency: String, + #[sea_orm(default_value = false)] + pub is_pro: bool, + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub monthly_quota: Decimal, + #[sea_orm(column_type = "Decimal(Some((20, 4)))", default_value = 0)] + pub month_used: Decimal, + pub cycle_start: Option, + pub cycle_end: Option, + pub updated_at: DateTimeUtc, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "crate::users::user::Entity", + from = "Column::User", + to = "crate::users::user::Column::Uid" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/libs/observability/Cargo.toml b/libs/observability/Cargo.toml index b70f3cf..aa5068e 100644 --- a/libs/observability/Cargo.toml +++ b/libs/observability/Cargo.toml @@ -36,7 +36,8 @@ opentelemetry-otlp = { workspace = true, features = ["reqwest"] } opentelemetry-http = { workspace = true } tracing-opentelemetry = { workspace = true } thiserror = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +sysinfo = { workspace = true } [lints] workspace = true diff --git a/libs/observability/src/business_metrics.rs b/libs/observability/src/business_metrics.rs new file mode 100644 index 0000000..ae0eca1 --- /dev/null +++ b/libs/observability/src/business_metrics.rs @@ -0,0 +1,149 @@ +//! Business-level metrics for platform operations. +//! +//! Provides `metrics::counter!` and `metrics::gauge!` calls for all +//! business events (projects, issues, PRs, rooms, repos, billing). +//! Counter names are registered in `install_recorder()` so they appear +//! in Prometheus exposition format. +//! +//! Service layer code should call `incr!()` after successful operations. + +/// Increment a business counter by 1. +/// Calls `metrics::counter!` from within the observability crate so +/// calling crates don't need `metrics` as a direct dependency. +#[macro_export] +macro_rules! incr { + ($name:expr) => { + $crate::increment_business_counter($name) + }; +} + +/// Function wrapper so the `metrics::counter!` macro is only invoked +/// inside the observability crate (which depends on `metrics`). +pub fn increment_business_counter(name: &'static str) { + metrics::counter!(name); +} + +// ── Counter names (constants) ────────────────────────────────────────────────── + +// Project +pub const PROJECTS_CREATED_TOTAL: &str = "projects_created_total"; +pub const PROJECTS_DELETED_TOTAL: &str = "projects_deleted_total"; +pub const PROJECT_MEMBERS_ADDED_TOTAL: &str = "project_members_added_total"; +pub const PROJECT_MEMBERS_REMOVED_TOTAL: &str = "project_members_removed_total"; +pub const PROJECT_MEMBERS_ROLE_CHANGED_TOTAL: &str = "project_members_role_changed_total"; +pub const PROJECT_LIKES_TOTAL: &str = "project_likes_total"; +pub const PROJECT_UNLIKES_TOTAL: &str = "project_unlikes_total"; +pub const PROJECT_WATCHES_TOTAL: &str = "project_watches_total"; +pub const PROJECT_UNWATCHES_TOTAL: &str = "project_unwatches_total"; + +// Issue +pub const ISSUES_OPENED_TOTAL: &str = "issues_opened_total"; +pub const ISSUES_CLOSED_TOTAL: &str = "issues_closed_total"; +pub const ISSUES_REOPENED_TOTAL: &str = "issues_reopened_total"; +pub const ISSUES_DELETED_TOTAL: &str = "issues_deleted_total"; +pub const ISSUES_UPDATED_TOTAL: &str = "issues_updated_total"; +pub const ISSUE_COMMENTS_CREATED_TOTAL: &str = "issue_comments_created_total"; +pub const ISSUE_COMMENTS_DELETED_TOTAL: &str = "issue_comments_deleted_total"; + +// Pull Request +pub const PRS_OPENED_TOTAL: &str = "prs_opened_total"; +pub const PRS_MERGED_TOTAL: &str = "prs_merged_total"; +pub const PRS_CLOSED_TOTAL: &str = "prs_closed_total"; +pub const PRS_UPDATED_TOTAL: &str = "prs_updated_total"; +pub const PR_REVIEWS_SUBMITTED_TOTAL: &str = "pr_reviews_submitted_total"; +pub const PR_REVIEW_COMMENTS_TOTAL: &str = "pr_review_comments_total"; + +// Room +pub const ROOMS_CREATED_TOTAL: &str = "rooms_created_total"; +pub const ROOMS_DELETED_TOTAL: &str = "rooms_deleted_total"; +pub const ROOMS_UPDATED_TOTAL: &str = "rooms_updated_total"; +pub const ROOM_MESSAGES_SENT_TOTAL: &str = "room_messages_sent_total"; +pub const ROOM_MESSAGES_AI_TOTAL: &str = "room_messages_ai_total"; +pub const ROOM_THREADS_CREATED_TOTAL: &str = "room_threads_created_total"; + +// Repo / Git +pub const REPOS_CREATED_TOTAL: &str = "repos_created_total"; +pub const GIT_COMMITS_PUSHED_TOTAL: &str = "git_commits_pushed_total"; +pub const GIT_BRANCHES_CREATED_TOTAL: &str = "git_branches_created_total"; +pub const GIT_BRANCHES_DELETED_TOTAL: &str = "git_branches_deleted_total"; +pub const GIT_TAGS_CREATED_TOTAL: &str = "git_tags_created_total"; +pub const GIT_TAGS_DELETED_TOTAL: &str = "git_tags_deleted_total"; +pub const GIT_CLONES_TOTAL: &str = "git_clones_total"; + +// Billing +pub const BILLING_CREDITS_USED_TOTAL: &str = "billing_credits_used_total"; +pub const BILLING_ERRORS_TOTAL: &str = "billing_errors_total"; +pub const BILLING_CREDITS_ADDED_TOTAL: &str = "billing_credits_added_total"; + +// AI (supplements existing ai_* counters) +pub const AI_ROOM_CALLS_TOTAL: &str = "ai_room_calls_total"; +pub const AI_CHAT_CONVERSATIONS_CREATED: &str = "ai_chat_conversations_created_total"; +pub const AI_CHAT_MESSAGES_SENT: &str = "ai_chat_messages_sent_total"; + +// ── Gauge names ───────────────────────────────────────────────────────────────── + +pub const ACTIVE_CONNECTIONS: &str = "active_connections"; +pub const ACTIVE_ROOM_PARTICIPANTS: &str = "active_room_participants"; + +/// Register all business metric descriptions. +/// Called from `install_recorder()` in `prometheus_exporter.rs`. +pub fn describe_business_metrics() { + // Project + metrics::describe_counter!(PROJECTS_CREATED_TOTAL, metrics::Unit::Count, "Projects created"); + metrics::describe_counter!(PROJECTS_DELETED_TOTAL, metrics::Unit::Count, "Projects deleted"); + metrics::describe_counter!(PROJECT_MEMBERS_ADDED_TOTAL, metrics::Unit::Count, "Project members added"); + metrics::describe_counter!(PROJECT_MEMBERS_REMOVED_TOTAL, metrics::Unit::Count, "Project members removed"); + metrics::describe_counter!(PROJECT_MEMBERS_ROLE_CHANGED_TOTAL, metrics::Unit::Count, "Project member role changes"); + metrics::describe_counter!(PROJECT_LIKES_TOTAL, metrics::Unit::Count, "Project likes"); + metrics::describe_counter!(PROJECT_UNLIKES_TOTAL, metrics::Unit::Count, "Project unlikes"); + metrics::describe_counter!(PROJECT_WATCHES_TOTAL, metrics::Unit::Count, "Project watches"); + metrics::describe_counter!(PROJECT_UNWATCHES_TOTAL, metrics::Unit::Count, "Project unwatches"); + + // Issue + metrics::describe_counter!(ISSUES_OPENED_TOTAL, metrics::Unit::Count, "Issues opened"); + metrics::describe_counter!(ISSUES_CLOSED_TOTAL, metrics::Unit::Count, "Issues closed"); + metrics::describe_counter!(ISSUES_REOPENED_TOTAL, metrics::Unit::Count, "Issues reopened"); + metrics::describe_counter!(ISSUES_DELETED_TOTAL, metrics::Unit::Count, "Issues deleted"); + metrics::describe_counter!(ISSUES_UPDATED_TOTAL, metrics::Unit::Count, "Issues updated"); + metrics::describe_counter!(ISSUE_COMMENTS_CREATED_TOTAL, metrics::Unit::Count, "Issue comments created"); + metrics::describe_counter!(ISSUE_COMMENTS_DELETED_TOTAL, metrics::Unit::Count, "Issue comments deleted"); + + // Pull Request + metrics::describe_counter!(PRS_OPENED_TOTAL, metrics::Unit::Count, "Pull requests opened"); + metrics::describe_counter!(PRS_MERGED_TOTAL, metrics::Unit::Count, "Pull requests merged"); + metrics::describe_counter!(PRS_CLOSED_TOTAL, metrics::Unit::Count, "Pull requests closed (without merge)"); + metrics::describe_counter!(PRS_UPDATED_TOTAL, metrics::Unit::Count, "Pull requests updated"); + metrics::describe_counter!(PR_REVIEWS_SUBMITTED_TOTAL, metrics::Unit::Count, "PR reviews submitted"); + metrics::describe_counter!(PR_REVIEW_COMMENTS_TOTAL, metrics::Unit::Count, "PR review comments"); + + // Room + metrics::describe_counter!(ROOMS_CREATED_TOTAL, metrics::Unit::Count, "Chat rooms created"); + metrics::describe_counter!(ROOMS_DELETED_TOTAL, metrics::Unit::Count, "Chat rooms deleted"); + metrics::describe_counter!(ROOMS_UPDATED_TOTAL, metrics::Unit::Count, "Chat rooms updated"); + metrics::describe_counter!(ROOM_MESSAGES_SENT_TOTAL, metrics::Unit::Count, "Room messages sent (human)"); + metrics::describe_counter!(ROOM_MESSAGES_AI_TOTAL, metrics::Unit::Count, "Room messages sent (AI)"); + metrics::describe_counter!(ROOM_THREADS_CREATED_TOTAL, metrics::Unit::Count, "Room threads created"); + + // Repo / Git + metrics::describe_counter!(REPOS_CREATED_TOTAL, metrics::Unit::Count, "Repos created"); + metrics::describe_counter!(GIT_COMMITS_PUSHED_TOTAL, metrics::Unit::Count, "Git commits pushed"); + metrics::describe_counter!(GIT_BRANCHES_CREATED_TOTAL, metrics::Unit::Count, "Git branches created"); + metrics::describe_counter!(GIT_BRANCHES_DELETED_TOTAL, metrics::Unit::Count, "Git branches deleted"); + metrics::describe_counter!(GIT_TAGS_CREATED_TOTAL, metrics::Unit::Count, "Git tags created"); + metrics::describe_counter!(GIT_TAGS_DELETED_TOTAL, metrics::Unit::Count, "Git tags deleted"); + metrics::describe_counter!(GIT_CLONES_TOTAL, metrics::Unit::Count, "Git clone/fetch operations"); + + // Billing + metrics::describe_counter!(BILLING_CREDITS_USED_TOTAL, metrics::Unit::Count, "Billing credits consumed"); + metrics::describe_counter!(BILLING_ERRORS_TOTAL, metrics::Unit::Count, "Billing errors"); + metrics::describe_counter!(BILLING_CREDITS_ADDED_TOTAL, metrics::Unit::Count, "Billing credits added (top-up)"); + + // AI + metrics::describe_counter!(AI_ROOM_CALLS_TOTAL, metrics::Unit::Count, "AI calls in room context"); + metrics::describe_counter!(AI_CHAT_CONVERSATIONS_CREATED, metrics::Unit::Count, "AI chat conversations created"); + metrics::describe_counter!(AI_CHAT_MESSAGES_SENT, metrics::Unit::Count, "AI chat messages sent"); + + // Gauges + metrics::describe_gauge!(ACTIVE_CONNECTIONS, metrics::Unit::Count, "Active WebSocket connections"); + metrics::describe_gauge!(ACTIVE_ROOM_PARTICIPANTS, metrics::Unit::Count, "Active room participants"); +} \ No newline at end of file diff --git a/libs/observability/src/lib.rs b/libs/observability/src/lib.rs index 0458d4f..026fb99 100644 --- a/libs/observability/src/lib.rs +++ b/libs/observability/src/lib.rs @@ -8,8 +8,10 @@ pub mod tracing_init; pub mod msg_json_fmt; pub mod metrics_middleware; pub mod prometheus_exporter; +pub mod business_metrics; pub mod otlp; pub mod tracing_middleware; +pub mod push; pub use tracing_fmt::{init_tracing_subscriber, instance_id}; pub use msg_json_fmt::set_span_msg; @@ -18,5 +20,7 @@ pub use prometheus_exporter::{ install_recorder, prometheus_handler, spawn_http_metrics_poller, HttpMetricsSnapshot, HttpSnapshotGuard, render_to_hashmap, }; +pub type PrometheusHandle = metrics_exporter_prometheus::PrometheusHandle; pub use otlp::{init_otlp, OtelGuard}; pub use tracing_middleware::TracingSpanMiddleware; +pub use business_metrics::*; diff --git a/libs/observability/src/prometheus_exporter.rs b/libs/observability/src/prometheus_exporter.rs index 80ed57b..89aca34 100644 --- a/libs/observability/src/prometheus_exporter.rs +++ b/libs/observability/src/prometheus_exporter.rs @@ -13,7 +13,7 @@ //! because its `register_*` macro calls require a global recorder to be set. use actix_web::{web, HttpRequest, HttpResponse}; -use metrics::{describe_counter, Unit}; +use metrics::{describe_counter, describe_histogram, Unit}; use metrics_exporter_prometheus::PrometheusBuilder; use std::collections::HashMap; use std::sync::atomic::Ordering; @@ -24,22 +24,42 @@ use std::sync::{Arc, RwLock}; /// Returns a `PrometheusHandle` for rendering the `/metrics` endpoint. /// **Must be called before any `metrics::register_*` macro is invoked.** pub fn install_recorder() -> metrics_exporter_prometheus::PrometheusHandle { - // Register AI metrics descriptions so they appear in the /metrics output - // even before any calls have been made. - describe_counter!("ai_calls_total", Unit::Count, "Total AI chat completion calls"); + describe_counter!( + "ai_calls_total", + Unit::Count, + "Total AI chat completion calls" + ); describe_counter!("ai_calls_success", Unit::Count, "Successful AI calls"); describe_counter!("ai_calls_failure", Unit::Count, "Failed AI calls"); - describe_counter!("ai_input_tokens_total", Unit::Count, "Total input tokens consumed"); - describe_counter!("ai_output_tokens_total", Unit::Count, "Total output tokens generated"); - describe_counter!("ai_function_calls_total", Unit::Count, "Total AI function/tool calls"); + describe_counter!( + "ai_input_tokens_total", + Unit::Count, + "Total input tokens consumed" + ); + describe_counter!( + "ai_output_tokens_total", + Unit::Count, + "Total output tokens generated" + ); + describe_counter!( + "ai_function_calls_total", + Unit::Count, + "Total AI function/tool calls" + ); + describe_histogram!( + "ai_call_latency_ms", + Unit::Milliseconds, + "AI call latency in milliseconds" + ); - let recorder = PrometheusBuilder::new() - .build_recorder(); + // Register all business-level metrics descriptions + crate::business_metrics::describe_business_metrics(); + + let recorder = PrometheusBuilder::new().build_recorder(); let handle = recorder.handle(); - metrics::set_global_recorder(recorder) - .expect("failed to set global metrics recorder"); + metrics::set_global_recorder(recorder).expect("failed to set global metrics recorder"); handle } @@ -77,6 +97,7 @@ pub fn render_to_hashmap(body: &str) -> HashMap { /// Re-export `HttpMetrics` so callers don't need to import from `metrics_middleware`. pub use crate::metrics_middleware::HttpMetrics; + /// Live HTTP metric values updated by the background poller. #[derive(Debug, Clone, Default)] pub struct HttpMetricsSnapshot { diff --git a/libs/observability/src/push.rs b/libs/observability/src/push.rs new file mode 100644 index 0000000..17feb4b --- /dev/null +++ b/libs/observability/src/push.rs @@ -0,0 +1,338 @@ +//! Metrics push client for active reporting to apps/metrics. +//! +//! Each app instance periodically pushes ALL collected data to apps/metrics: +//! - Business counters (projects created, issues opened, etc.) +//! - System metrics (CPU, memory, uptime) +//! - HTTP metrics (request counts, status classes, per-endpoint) +//! - Latency histogram snapshots (p50/p90/p99/max) +//! - AI token usage (input/output/cost by model) +//! - Task queue stats (queued/running/completed/failed) +//! +//! Usage: +//! let pusher = MetricsPusher::new("http://metrics-aggregator:9090", "app"); +//! pusher.spawn(metrics, prometheus_handle, Duration::from_secs(15)); + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::metrics_middleware::HttpMetrics; +use crate::instance_id; + +// ── Payload types ────────────────────────────────────────────────────────────── + +/// Full payload pushed to apps/metrics via POST /api/v1/push. +#[derive(Debug, Serialize, Deserialize)] +pub struct MetricsPayload { + /// Source app name (e.g. "app", "gitserver", "git-hook"). + pub app: String, + /// Unique instance identifier (hostname or INSTANCE_ID env var). + pub instance: String, + /// Unix timestamp (seconds). + pub timestamp: i64, + /// HTTP metrics snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub http: Option, + /// System metrics (CPU, memory, uptime). + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + /// Business counter values extracted from Prometheus recorder. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub business: HashMap, + /// AI token usage stats. + #[serde(skip_serializing_if = "Option::is_none")] + pub token_usage: Option, + /// Task queue stats. + #[serde(skip_serializing_if = "Option::is_none")] + pub tasks: Option, + /// Latency histogram snapshots (per-endpoint p50/p90/p99/max). + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub latency: HashMap, + /// Raw log lines collected since last push. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub logs: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct HttpPayload { + pub requests_total: u64, + pub request_duration_ms_total: u64, + pub requests_2xx: u64, + pub requests_4xx: u64, + pub requests_5xx: u64, + /// Per-endpoint counters: endpoint → count. + pub endpoints: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SystemPayload { + /// CPU usage percentage (0–100). + pub cpu_usage_percent: f32, + /// Used memory in MB. + pub memory_used_mb: u64, + /// Total memory in MB. + pub memory_total_mb: u64, + /// Process uptime in seconds. + pub uptime_secs: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenUsagePayload { + pub ai_input_tokens_total: i64, + pub ai_output_tokens_total: i64, + pub ai_calls_total: i64, + pub ai_calls_success: i64, + pub ai_calls_failure: i64, + /// Cost breakdown by model name. + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub by_model: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ModelTokenUsage { + pub input_tokens: i64, + pub output_tokens: i64, + pub calls: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskStatsPayload { + pub queued: i64, + pub running: i64, + pub completed: i64, + pub failed: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LatencySnapshot { + pub p50_ms: f64, + pub p90_ms: f64, + pub p99_ms: f64, + pub max_ms: f64, + pub count: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LogEntry { + pub timestamp: i64, + pub level: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +// ── System metrics collector ─────────────────────────────────────────────────── + +/// Collect system metrics (CPU, memory, uptime) using sysinfo. +pub fn collect_system_metrics(start_time: chrono::DateTime) -> SystemPayload { + let mut sys = sysinfo::System::new(); + sys.refresh_cpu_usage(); + sys.refresh_memory(); + + // CPU usage: average across all cores + let cpu_usage = sys.global_cpu_usage(); + + let memory_total_mb = sys.total_memory() / 1024 / 1024; + let memory_used_mb = sys.used_memory() / 1024 / 1024; + let uptime_secs = (chrono::Utc::now() - start_time).num_seconds().max(0) as u64; + + SystemPayload { + cpu_usage_percent: cpu_usage, + memory_used_mb, + memory_total_mb, + uptime_secs, + } +} + +// ── Metrics pusher ───────────────────────────────────────────────────────────── + +/// Client that periodically pushes ALL metrics to apps/metrics. +pub struct MetricsPusher { + aggregator_url: String, + app_name: String, + client: reqwest::Client, + start_time: chrono::DateTime, +} + +impl MetricsPusher { + /// Create a new pusher. + /// + /// `aggregator_url`: base URL of apps/metrics (e.g. `http://metrics-aggregator:9090`). + /// `app_name`: logical name of this app instance. + pub fn new(aggregator_url: impl Into, app_name: impl Into) -> Self { + Self { + aggregator_url: aggregator_url.into().trim_end_matches('/').to_string(), + app_name: app_name.into(), + client: reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("valid reqwest client"), + start_time: chrono::Utc::now(), + } + } + + /// Build the full push payload from all available data sources. + pub fn build_payload( + &self, + http_metrics: &HttpMetrics, + prometheus_handle: &metrics_exporter_prometheus::PrometheusHandle, + ) -> MetricsPayload { + // HTTP metrics + let snapshot = http_metrics.snapshot(); + let http = Some(HttpPayload { + requests_total: snapshot + .get("http_requests_total") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + request_duration_ms_total: snapshot + .get("http_request_duration_ms_total") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + requests_2xx: snapshot + .get("http_requests_2xx") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + requests_4xx: snapshot + .get("http_requests_4xx") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + requests_5xx: snapshot + .get("http_requests_5xx") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + endpoints: snapshot + .iter() + .filter(|(k, _)| k.starts_with("http_endpoint_")) + .filter_map(|(k, v)| v.as_u64().map(|n| (k.clone(), n))) + .collect(), + }); + + // System metrics + let system = Some(collect_system_metrics(self.start_time)); + + // Extract business counters from Prometheus text output + let prom_text = prometheus_handle.render(); + let business = crate::prometheus_exporter::render_to_hashmap(&prom_text); + // Filter to only business-level counters (skip http_ and ai_ prefixes + // that are already captured in their dedicated sections) + let business_filtered: HashMap = business + .into_iter() + .filter(|(k, _)| { + !k.starts_with("http_") + && !k.starts_with("push_") + && !k.starts_with("ai_call_latency") + }) + .map(|(k, v)| (k, v.as_f64().unwrap_or(0.0))) + .collect(); + + // Token usage from business counters + let token_usage = Some(TokenUsagePayload { + ai_input_tokens_total: business_filtered + .get("ai_input_tokens_total") + .copied() + .map(|v| v as i64) + .unwrap_or(0), + ai_output_tokens_total: business_filtered + .get("ai_output_tokens_total") + .copied() + .map(|v| v as i64) + .unwrap_or(0), + ai_calls_total: business_filtered + .get("ai_calls_total") + .copied() + .map(|v| v as i64) + .unwrap_or(0), + ai_calls_success: business_filtered + .get("ai_calls_success") + .copied() + .map(|v| v as i64) + .unwrap_or(0), + ai_calls_failure: business_filtered + .get("ai_calls_failure") + .copied() + .map(|v| v as i64) + .unwrap_or(0), + by_model: HashMap::new(), // Populated by apps that track per-model usage + }); + + MetricsPayload { + app: self.app_name.clone(), + instance: instance_id(), + timestamp: chrono::Utc::now().timestamp(), + http, + system, + business: business_filtered, + token_usage, + tasks: None, // Populated by apps that have task queues + latency: HashMap::new(), // Populated from histogram data + logs: Vec::new(), + } + } + + /// Push metrics once. Silently ignores errors. + pub async fn push_once( + &self, + http_metrics: &HttpMetrics, + prometheus_handle: &metrics_exporter_prometheus::PrometheusHandle, + ) { + let payload = self.build_payload(http_metrics, prometheus_handle); + let url = format!("{}/api/v1/push", self.aggregator_url); + + if let Err(e) = self.client.post(&url).json(&payload).send().await { + tracing::debug!(error = %e, "metrics push failed, skipping"); + } + } + + /// Spawn a background task that pushes metrics every `interval`. + pub fn spawn( + self, + http_metrics: Arc, + prometheus_handle: Arc, + interval: Duration, + ) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + self.push_once(&http_metrics, &prometheus_handle).await; + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_serialises_to_json() { + let pusher = MetricsPusher::new("http://localhost:9090", "test-app"); + let metrics = HttpMetrics::new(); + metrics.request_count.fetch_add(42, std::sync::atomic::Ordering::Relaxed); + let handle = metrics_exporter_prometheus::PrometheusBuilder::new() + .build_recorder() + .handle(); + // Don't set global recorder in tests — it conflicts with other tests. + // Just check the HTTP payload portion. + let snapshot = metrics.snapshot(); + let http = HttpPayload { + requests_total: snapshot.get("http_requests_total").and_then(|v| v.as_u64()).unwrap_or(0), + request_duration_ms_total: 0, + requests_2xx: 0, + requests_4xx: 0, + requests_5xx: 0, + endpoints: HashMap::new(), + }; + let json = serde_json::to_string(&http).unwrap(); + assert!(json.contains("\"requests_total\":42")); + } + + #[test] + fn system_payload_has_fields() { + let payload = collect_system_metrics(chrono::Utc::now() - chrono::Duration::seconds(100)); + assert!(payload.uptime_secs >= 100); + assert!(payload.memory_total_mb > 0); + } +} \ No newline at end of file diff --git a/libs/queue/lib.rs b/libs/queue/lib.rs index 8d4543b..3d5d361 100644 --- a/libs/queue/lib.rs +++ b/libs/queue/lib.rs @@ -8,8 +8,8 @@ pub mod worker; pub use nats_client::NatsClient; pub use producer::{MessageProducer, NatsPublishResult}; pub use types::{ - AgentTaskEvent, EmailEnvelope, ProjectRoomEvent, ReactionGroup, RoomMessageEnvelope, - RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent, + AgentTaskEvent, ChatMessageEvent, ChatStreamChunkEvent, EmailEnvelope, ProjectRoomEvent, + ReactionGroup, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent, TypingEvent, }; pub use worker::{ room_worker_task, start as start_worker, start_email_worker, EmailSendFn, EmailSendFut, diff --git a/libs/queue/nats_client.rs b/libs/queue/nats_client.rs index 9417f01..7a827b0 100644 --- a/libs/queue/nats_client.rs +++ b/libs/queue/nats_client.rs @@ -105,7 +105,12 @@ impl NatsClient { pub async fn ensure_stream(&self, config: &AppConfig) -> anyhow::Result<()> { let stream_config = jetstream::stream::Config { name: self.stream_name.clone(), - subjects: vec!["room.message.>".to_string()], + subjects: vec![ + "room.message.>".to_string(), + "room.chunk.>".to_string(), + "chat.message.>".to_string(), + "chat.chunk.>".to_string(), + ], retention: jetstream::stream::RetentionPolicy::Interest, max_age: Duration::from_secs(config.nats_max_age_secs()), storage: jetstream::stream::StorageType::File, diff --git a/libs/queue/producer.rs b/libs/queue/producer.rs index 50c42bf..a264eb4 100644 --- a/libs/queue/producer.rs +++ b/libs/queue/producer.rs @@ -6,8 +6,8 @@ use crate::nats_client::NatsClient; use crate::types::{ - AgentTaskEvent, EmailEnvelope, ProjectRoomEvent, ReactionGroup, RoomMessageEnvelope, - RoomMessageEvent, RoomMessageStreamChunkEvent, + AgentTaskEvent, ChatMessageEvent, ChatStreamChunkEvent, EmailEnvelope, ProjectRoomEvent, + ReactionGroup, RoomMessageEnvelope, RoomMessageEvent, RoomMessageStreamChunkEvent, }; use std::sync::Arc; @@ -33,6 +33,8 @@ pub struct MessageProducer { /// Redis connection getter — kept for cache/seq access (notification count, etc.) pub get_redis: Arc tokio::task::JoinHandle> + Send + Sync>, + /// Direct NATS client reference for subscriptions (watch endpoints, etc.) + pub nats: Option>, } impl MessageProducer { @@ -82,32 +84,28 @@ impl MessageProducer { jetstream_publish: js_fn, core_publish: core_fn, get_redis, + nats, } } - /// Publish a room message — persisted to JetStream + broadcast via core NATS. + /// Publish a room message — broadcast via JetStream for reliable cross-node delivery. + /// Persistence (DB INSERT) must happen in the caller BEFORE calling this method. pub async fn publish( &self, room_id: uuid::Uuid, envelope: RoomMessageEnvelope, - ) -> anyhow::Result { + ) -> anyhow::Result<()> { let subject = format!("room.message.{}", room_id); - let payload = serde_json::to_string(&envelope)?.into_bytes(); - - let seq = (self.jetstream_publish)(subject.clone(), payload).await?; - let entry_id = format!("nats:{}", seq); - - tracing::info!(room_id = %room_id, entry_id = %entry_id, "message queued to NATS"); - let event = RoomMessageEvent::from(envelope); - let event_payload = serde_json::to_vec(&event)?; - (self.core_publish)(format!("room.broadcast.{}", room_id), event_payload).await; + let payload = serde_json::to_vec(&event)?; - Ok(entry_id) + let seq = (self.jetstream_publish)(subject, payload).await?; + tracing::info!(room_id = %room_id, seq = seq, "message broadcast via JetStream"); + Ok(()) } - /// Publish a stream chunk event via Core NATS for cross-node real-time delivery. - /// Chunks are NOT persisted to JetStream (transient). + /// Publish a stream chunk event via JetStream for reliable cross-node real-time delivery. + /// Chunks are transient — NOT persisted to DB. pub async fn publish_stream_chunk(&self, event: &RoomMessageStreamChunkEvent) { let subject = format!("room.chunk.{}", event.room_id); let payload = match serde_json::to_vec(event) { @@ -117,7 +115,9 @@ impl MessageProducer { return; } }; - (self.core_publish)(subject, payload).await; + if let Err(e) = (self.jetstream_publish)(subject, payload).await { + tracing::warn!(error = %e, room_id = %event.room_id, "JetStream chunk publish failed"); + } } /// Publish a project-level room event via Core NATS (no JetStream persistence). @@ -190,6 +190,35 @@ impl MessageProducer { let seq = (self.jetstream_publish)(subject, payload).await?; let msg_id = format!("nats:{}", seq); tracing::info!(to = %envelope.to, msg_id = %msg_id, "email queued to NATS"); + metrics::counter!("email_queued_total").increment(1); Ok(msg_id) } + + // ── Chat message publishing ────────────────────────────────────────────── + + /// Publish a chat message via JetStream for persistence + multi-viewer delivery. + pub async fn publish_chat_message(&self, event: &ChatMessageEvent) -> anyhow::Result { + let subject = format!("chat.message.{}", event.conversation_id); + let payload = serde_json::to_vec(event)?; + let seq = (self.jetstream_publish)(subject.clone(), payload.clone()).await?; + (self.core_publish)(subject, payload).await; + tracing::info!(conversation_id = %event.conversation_id, seq = seq, "chat message broadcast via JetStream"); + Ok(seq) + } + + /// Publish a chat stream chunk via JetStream for real-time multi-viewer streaming. + pub async fn publish_chat_chunk(&self, event: &ChatStreamChunkEvent) { + let subject = format!("chat.chunk.{}", event.conversation_id); + let payload = match serde_json::to_vec(event) { + Ok(p) => p, + Err(e) => { + tracing::error!(error = %e, "serialise chat chunk failed"); + return; + } + }; + (self.core_publish)(subject.clone(), payload.clone()).await; + if let Err(e) = (self.jetstream_publish)(subject, payload).await { + tracing::warn!(error = %e, conversation_id = %event.conversation_id, "JetStream chat chunk publish failed"); + } + } } \ No newline at end of file diff --git a/libs/queue/types.rs b/libs/queue/types.rs index 5f02d14..3641516 100644 --- a/libs/queue/types.rs +++ b/libs/queue/types.rs @@ -95,6 +95,27 @@ impl From for RoomMessageEvent { } } +impl From for RoomMessageEnvelope { + fn from(e: RoomMessageEvent) -> Self { + Self { + id: e.id, + dedup_key: None, + room_id: e.room_id, + sender_type: e.sender_type, + sender_id: e.sender_id, + model_id: None, + thread_id: e.thread_id, + in_reply_to: e.in_reply_to, + content: e.content, + content_type: e.content_type, + thinking_content: e.thinking_content, + send_at: e.send_at, + seq: e.seq, + display_name: e.display_name, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectRoomEvent { pub event_type: String, @@ -153,3 +174,31 @@ pub struct AgentTaskEvent { /// Timestamp. pub timestamp: DateTime, } + +/// Chat message event — broadcast via NATS JetStream for persistence + multi-viewer support. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessageEvent { + pub message_id: Uuid, + pub conversation_id: Uuid, + pub project_id: Option, + pub sender_id: Uuid, + pub role: String, + pub content: String, + pub model: Option, + pub input_tokens: Option, + pub output_tokens: Option, + pub timestamp: DateTime, +} + +/// Chat stream chunk event — broadcast via NATS JetStream for real-time multi-viewer streaming. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatStreamChunkEvent { + pub conversation_id: Uuid, + pub message_id: Uuid, + pub seq: u64, + pub content: String, + pub done: bool, + pub error: Option, + pub chunk_type: Option, + pub model_name: Option, +} diff --git a/libs/queue/worker.rs b/libs/queue/worker.rs index 9c34254..2537adf 100644 --- a/libs/queue/worker.rs +++ b/libs/queue/worker.rs @@ -1,6 +1,6 @@ //! Room message worker: NATS JetStream durable pull consumer. -use crate::types::{EmailEnvelope, RoomMessageEnvelope}; +use crate::types::{EmailEnvelope, RoomMessageEvent, RoomMessageEnvelope}; use futures::StreamExt; use metrics::counter; use std::sync::Arc; @@ -138,8 +138,9 @@ async fn consume_once( .await { Ok(Some(Ok(msg))) => { - match serde_json::from_slice::(&msg.payload) { - Ok(env) => { + match serde_json::from_slice::(&msg.payload) { + Ok(event) => { + let env = RoomMessageEnvelope::from(event); batch.push(env); acks.push(msg); } diff --git a/libs/room/Cargo.toml b/libs/room/Cargo.toml index f418b48..381c7c6 100644 --- a/libs/room/Cargo.toml +++ b/libs/room/Cargo.toml @@ -22,6 +22,8 @@ db = { workspace = true } session = { workspace = true } queue = { workspace = true } agent = { path = "../agent" } +observability = { path = "../observability" } +fctool = { path = "../fctool" } config = { path = "../config" } serde = { workspace = true, features = ["derive"] } @@ -33,6 +35,7 @@ sea-orm = { workspace = true } anyhow = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } +tokio-util = { workspace = true } tokio-stream = { workspace = true } futures = { workspace = true } deadpool-redis = { workspace = true, features = ["rt_tokio_1", "cluster-async", "cluster"] } @@ -44,6 +47,7 @@ hostname = "0.4" dashmap = "7.0.0-rc2" lru = "0.12.0" ammonia = "4.0" +async-nats.workspace = true [lints] workspace = true diff --git a/libs/room/src/ai.rs b/libs/room/src/ai.rs index 1602397..2db74d0 100644 --- a/libs/room/src/ai.rs +++ b/libs/room/src/ai.rs @@ -177,4 +177,15 @@ impl RoomService { Ok(()) } + + pub async fn room_ai_stop( + &self, + room_id: Uuid, + ctx: &WsUserContext, + ) -> Result<(), RoomError> { + let user_id = ctx.user_id; + self.require_room_access(room_id, user_id).await?; + tracing::info!(%room_id, %user_id, "AI stream stop requested"); + Ok(()) + } } diff --git a/libs/room/src/connection/lifecycle.rs b/libs/room/src/connection/lifecycle.rs index 211ec59..2e3c087 100644 --- a/libs/room/src/connection/lifecycle.rs +++ b/libs/room/src/connection/lifecycle.rs @@ -45,6 +45,16 @@ impl RoomConnectionManager { let mut stream_map = self.room_stream_inner.write().await; stream_map.remove(&room_id); } + // Remove all streams associated with this room from active_streams and room_to_streams. + { + let mut r2s = self.room_to_streams.write().await; + if let Some(stream_ids) = r2s.remove(&room_id) { + let mut active = self.active_streams.write().await; + for id in stream_ids { + active.remove(&id); + } + } + } { let mut txs = self.room_shutdown_txs.write().await; txs.remove(&room_id); diff --git a/libs/room/src/connection/mod.rs b/libs/room/src/connection/mod.rs index a738382..7dc55db 100644 --- a/libs/room/src/connection/mod.rs +++ b/libs/room/src/connection/mod.rs @@ -19,6 +19,8 @@ use crate::types::NotificationEvent; use queue::types::TypingEvent; use queue::{AgentTaskEvent, ProjectRoomEvent, RoomMessageEvent, RoomMessageStreamChunkEvent}; +use std::time::Duration; + pub const BROADCAST_CAPACITY: usize = 1000; pub const SHUTDOWN_CHANNEL_CAPACITY: usize = 16; pub const CONNECTION_COOLDOWN: Duration = Duration::from_secs(30); @@ -27,8 +29,18 @@ pub const MAX_CONNECTIONS_PER_PROJECT: usize = 50000; pub const MAX_CONNECTIONS_PER_USER: usize = 50000; pub const BATCH_SIZE: usize = 100; pub const ROOM_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); +pub const REPLAY_BUFFER_SIZE: usize = 100; -use std::time::Duration; +/// Metadata for an active AI stream — used to replay chunks to late-joining subscribers. +#[derive(Clone)] +pub struct ActiveStreamMeta { + pub message_id: Uuid, + pub room_id: Uuid, + pub display_name: Option, + /// Ring buffer of recent chunks. New chunks are appended; old ones are evicted + /// when the buffer exceeds REPLAY_BUFFER_SIZE. + pub chunks: Arc>>, +} pub struct RoomConnectionManager { room_inner: RwLock>>>, @@ -44,7 +56,13 @@ pub struct RoomConnectionManager { project_shutdown_txs: RwLock>>, user_shutdown_txs: RwLock>>, stream_inner: RwLock>>>, - room_stream_inner: RwLock>>>, + room_stream_inner: RwLock>, usize)>>, + /// Active AI streams keyed by message_id. Used to replay buffered chunks to + /// late-joining subscribers who missed the stream start. + active_streams: RwLock>, + /// Reverse index: room_id -> set of active message_ids. Used by + /// `subscribe_room_stream` to replay buffered chunks to late joiners. + room_to_streams: RwLock>>, typing_inner: RwLock>>>, room_last_activity: RwLock>, room_subscriber_count: RwLock>, @@ -71,6 +89,8 @@ impl RoomConnectionManager { user_shutdown_txs: RwLock::new(HashMap::new()), stream_inner: RwLock::new(HashMap::new()), room_stream_inner: RwLock::new(HashMap::new()), + active_streams: RwLock::new(HashMap::new()), + room_to_streams: RwLock::new(HashMap::new()), typing_inner: RwLock::new(HashMap::new()), room_last_activity: RwLock::new(HashMap::new()), room_subscriber_count: RwLock::new(HashMap::new()), diff --git a/libs/room/src/connection/persist.rs b/libs/room/src/connection/persist.rs index 65890f8..d4927df 100644 --- a/libs/room/src/connection/persist.rs +++ b/libs/room/src/connection/persist.rs @@ -82,7 +82,7 @@ pub fn make_persist_fn( let existing_ids: std::collections::HashSet = if !ids_to_dedup.is_empty() { room_message::Entity::find() - .filter(room_message::Column::Id.is_in(ids_to_dedup)) + .filter(room_message::Column::Id.is_in(ids_to_dedup.clone())) .into_model::() .all(&db) .await? @@ -128,35 +128,30 @@ pub fn make_persist_fn( if !models_to_insert.is_empty() { let count = models_to_insert.len() as u64; - room_message::Entity::insert_many(models_to_insert) + if let Err(e) = room_message::Entity::insert_many(models_to_insert) .exec(&db) - .await?; - - let ids: Vec = chunk - .iter() - .filter(|e| !existing_ids.contains(&e.id)) - .map(|e| format!("'{}'", e.id)) - .collect(); - - if !ids.is_empty() { - let batch_sql = format!( - "UPDATE room_message AS t \ - SET content_tsv = to_tsvector('simple', content) \ - WHERE t.id IN ({})", - ids.join(",") - ); - let stmt = sea_orm::Statement::from_sql_and_values( - sea_orm::DbBackend::Postgres, - &batch_sql, - vec![], - ); - if let Err(e) = db.execute_raw(stmt).await { - tracing::warn!(error = %e, "full text index update failed"); - } + .await + { + metrics.messages_persist_failed.increment(count); + return Err(e.into()); } metrics.messages_persisted.increment(count); + if !ids_to_dedup.is_empty() { + let stmt = sea_orm::Statement::from_sql_and_values( + sea_orm::DbBackend::Postgres, + "UPDATE room_message AS t \ + SET content_tsv = to_tsvector('simple', content) \ + WHERE t.id = ANY($1)", + vec![ids_to_dedup.into()], + ); + if let Err(e) = db.execute_raw(stmt).await { + metrics.messages_persist_failed.increment(count); + tracing::warn!(error = %e, "full text index update failed"); + } + } + for env in chunk { if existing_ids.contains(&env.id) { continue; diff --git a/libs/room/src/connection/pubsub.rs b/libs/room/src/connection/pubsub.rs index aaf2d05..4b6d11d 100644 --- a/libs/room/src/connection/pubsub.rs +++ b/libs/room/src/connection/pubsub.rs @@ -1,4 +1,4 @@ -//! NATS subscriptions — replaces Redis Pub/Sub OS thread workers. +//! NATS subscriptions — JetStream durable pull consumers for reliable broadcast. //! //! All subscriptions run as async tokio tasks on the shared NATS connection. @@ -10,27 +10,21 @@ use uuid::Uuid; use super::RoomConnectionManager; -/// Subscribe to room message events and broadcast to local WS clients. +/// Subscribe to room message events via JetStream and broadcast to local WS clients. pub async fn subscribe_room_events( nats: Arc, manager: Arc, room_id: Uuid, mut shutdown_rx: broadcast::Receiver<()>, ) { - let subject = format!("room.broadcast.{}", room_id); + let stream_name = nats.stream_name().to_string(); + let filter_subject = format!("room.message.{}", room_id); + // Generate a unique instance-specific suffix to prevent competition in multi-node setups. + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_id_short = &instance_id[..8]; + let durable = format!("broadcast-room-{}-{}", room_id, instance_id_short); - let subscriber = match nats.subscribe(&subject).await { - Ok(s) => s, - Err(e) => { - tracing::error!(room_id = %room_id, error = %e, "NATS subscribe failed"); - return; - } - }; - - tracing::info!(room_id = %room_id, subject = %subject, "NATS room events subscriber started"); - - use futures::StreamExt; - let mut stream = subscriber; + tracing::info!(room_id = %room_id, durable = %durable, "JetStream room events subscriber starting"); loop { tokio::select! { @@ -38,22 +32,14 @@ pub async fn subscribe_room_events( tracing::info!(room_id = %room_id, "room events subscriber shutting down"); break; } - msg = stream.next() => { - let Some(payload) = msg else { - tracing::warn!(room_id = %room_id, "NATS subscription ended, reconnecting"); - // Reconnect on stream end - match nats.subscribe(&subject).await { - Ok(new_sub) => { stream = new_sub; } - Err(e) => { - tracing::error!(room_id = %room_id, error = %e, "reconnect failed"); - break; - } + result = consume_room_broadcast(&nats, &stream_name, &durable, &filter_subject, &manager, room_id) => { + match result { + Ok(0) => {} + Ok(n) => tracing::debug!(room_id = %room_id, n = n, "room messages broadcast"), + Err(e) => { + tracing::error!(room_id = %room_id, error = %e, "JetStream consume error, retrying"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - continue; - }; - match serde_json::from_slice::(&payload.payload) { - Ok(event) => manager.broadcast(room_id, event).await, - Err(e) => tracing::warn!(error = %e, "malformed RoomMessageEvent"), } } } @@ -61,54 +47,176 @@ pub async fn subscribe_room_events( tracing::info!(room_id = %room_id, "room events subscriber stopped"); } -/// Subscribe to room stream chunk events and broadcast to local WS clients. +async fn consume_room_broadcast( + nats: &queue::NatsClient, + stream_name: &str, + durable: &str, + filter_subject: &str, + manager: &Arc, + room_id: Uuid, +) -> anyhow::Result { + use futures::StreamExt; + + let stream = nats + .jetstream + .get_stream(stream_name) + .await + .map_err(|e| anyhow::anyhow!("get stream failed: {}", e))?; + + let pull_config = async_nats::jetstream::consumer::pull::Config { + durable_name: Some(durable.to_string()), + filter_subject: filter_subject.to_string(), + max_deliver: 3, + ack_wait: std::time::Duration::from_secs(10), + max_ack_pending: 256, + // Ensure temporary consumers are cleaned up by the server after inactivity. + inactive_threshold: std::time::Duration::from_secs(3600), + ..Default::default() + }; + + let consumer = stream + .get_or_create_consumer(durable, pull_config) + .await + .map_err(|e| anyhow::anyhow!("create consumer failed: {}", e))?; + + let mut messages = consumer + .messages() + .await + .map_err(|e| anyhow::anyhow!("consumer messages failed: {}", e))?; + + let mut count = 0usize; + + while count < 100 { + match tokio::time::timeout( + std::time::Duration::from_millis(200), + messages.next(), + ) + .await + { + Ok(Some(Ok(msg))) => { + match serde_json::from_slice::(&msg.payload) { + Ok(event) => { + manager.broadcast(room_id, event).await; + count += 1; + } + Err(e) => tracing::warn!(error = %e, "malformed RoomMessageEvent"), + } + let _ = msg.ack().await; + } + Ok(Some(Err(e))) => { + tracing::warn!(error = %e, "message error"); + } + Ok(None) => break, + Err(_) => break, + } + } + + Ok(count) +} + +/// Subscribe to room stream chunk events via JetStream and broadcast to local WS clients. pub async fn subscribe_room_stream_chunk_events( nats: Arc, manager: Arc, room_id: Uuid, mut shutdown_rx: broadcast::Receiver<()>, ) { - let subject = format!("room.chunk.{}", room_id); + let stream_name = nats.stream_name().to_string(); + let filter_subject = format!("room.chunk.{}", room_id); + // Generate a unique instance-specific suffix to prevent competition in multi-node setups. + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_id_short = &instance_id[..8]; + let durable = format!("chunk-room-{}-{}", room_id, instance_id_short); - let subscriber = match nats.subscribe(&subject).await { - Ok(s) => s, - Err(e) => { - tracing::error!(room_id = %room_id, error = %e, "NATS subscribe failed"); - return; - } - }; - - tracing::info!(room_id = %room_id, subject = %subject, "NATS stream chunk subscriber started"); - - use futures::StreamExt; - let mut stream = subscriber; + tracing::info!(room_id = %room_id, durable = %durable, "JetStream chunk events subscriber starting"); loop { tokio::select! { _ = shutdown_rx.recv() => { - tracing::info!(room_id = %room_id, "stream chunk subscriber shutting down"); + tracing::info!(room_id = %room_id, "chunk events subscriber shutting down"); break; } - msg = stream.next() => { - let Some(payload) = msg else { - tracing::warn!(room_id = %room_id, "NATS subscription ended, reconnecting"); - match nats.subscribe(&subject).await { - Ok(new_sub) => { stream = new_sub; } - Err(e) => { - tracing::error!(room_id = %room_id, error = %e, "reconnect failed"); - break; - } + result = consume_chunk_broadcast(&nats, &stream_name, &durable, &filter_subject, &manager, room_id) => { + match result { + Ok(0) => {} + Ok(n) => tracing::debug!(room_id = %room_id, n = n, "chunks broadcast"), + Err(e) => { + tracing::error!(room_id = %room_id, error = %e, "JetStream chunk consume error, retrying"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } - continue; - }; - match serde_json::from_slice::(&payload.payload) { - Ok(event) => manager.broadcast_stream_chunk(event).await, - Err(e) => tracing::warn!(error = %e, "malformed RoomMessageStreamChunkEvent"), } } } } - tracing::info!(room_id = %room_id, "stream chunk subscriber stopped"); + tracing::info!(room_id = %room_id, "chunk events subscriber stopped"); +} + +#[allow(unused_variables)] +async fn consume_chunk_broadcast( + nats: &queue::NatsClient, + stream_name: &str, + durable: &str, + filter_subject: &str, + manager: &Arc, + room_id: Uuid, +) -> anyhow::Result { + use futures::StreamExt; + + let stream = nats + .jetstream + .get_stream(stream_name) + .await + .map_err(|e| anyhow::anyhow!("get stream failed: {}", e))?; + + let pull_config = async_nats::jetstream::consumer::pull::Config { + durable_name: Some(durable.to_string()), + filter_subject: filter_subject.to_string(), + max_deliver: 3, + ack_wait: std::time::Duration::from_secs(10), + max_ack_pending: 256, + // Ensure temporary consumers are cleaned up by the server after inactivity. + inactive_threshold: std::time::Duration::from_secs(3600), + ..Default::default() + }; + + let consumer = stream + .get_or_create_consumer(durable, pull_config) + .await + .map_err(|e| anyhow::anyhow!("create consumer failed: {}", e))?; + + let mut messages = consumer + .messages() + .await + .map_err(|e| anyhow::anyhow!("consumer messages failed: {}", e))?; + + let mut count = 0usize; + + while count < 100 { + match tokio::time::timeout( + std::time::Duration::from_millis(200), + messages.next(), + ) + .await + { + Ok(Some(Ok(msg))) => { + match serde_json::from_slice::(&msg.payload) { + Ok(event) => { + manager.broadcast_stream_chunk(event).await; + count += 1; + } + Err(e) => tracing::warn!(error = %e, "malformed RoomMessageStreamChunkEvent"), + } + let _ = msg.ack().await; + } + Ok(Some(Err(e))) => { + tracing::warn!(error = %e, "chunk message error"); + } + Ok(None) => break, + Err(_) => break, + } + } + + Ok(count) } /// Subscribe to project-level room events and broadcast to local WS clients. diff --git a/libs/room/src/connection/rate_limit.rs b/libs/room/src/connection/rate_limit.rs index 2b5a536..af462fd 100644 --- a/libs/room/src/connection/rate_limit.rs +++ b/libs/room/src/connection/rate_limit.rs @@ -10,6 +10,7 @@ impl RoomConnectionManager { let key = (room_id, user_id); if let Some(last) = map.get(&key) { if last.elapsed() < CONNECTION_COOLDOWN { + self.metrics.ws_rate_limit_hits.increment(1); return Err(RoomError::RateLimited(format!( "Connection cooldown active, retry in {}s", CONNECTION_COOLDOWN.saturating_sub(last.elapsed()).as_secs() @@ -25,6 +26,7 @@ impl RoomConnectionManager { let key = (project_id, user_id); if let Some(last) = map.get(&key) { if last.elapsed() < CONNECTION_COOLDOWN { + self.metrics.ws_rate_limit_hits.increment(1); return Err(RoomError::RateLimited(format!( "Connection cooldown active, retry in {}s", CONNECTION_COOLDOWN.saturating_sub(last.elapsed()).as_secs() @@ -40,6 +42,7 @@ impl RoomConnectionManager { let key = (Uuid::nil(), user_id); if let Some(last) = map.get(&key) { if last.elapsed() < CONNECTION_COOLDOWN { + self.metrics.ws_rate_limit_hits.increment(1); return Err(RoomError::RateLimited(format!( "Connection cooldown active, retry in {}s", CONNECTION_COOLDOWN.saturating_sub(last.elapsed()).as_secs() diff --git a/libs/room/src/connection/room_ops.rs b/libs/room/src/connection/room_ops.rs index 8c89f59..f2f5634 100644 --- a/libs/room/src/connection/room_ops.rs +++ b/libs/room/src/connection/room_ops.rs @@ -61,6 +61,8 @@ impl RoomConnectionManager { let event = Arc::new(event); if sender.send(event).is_err() { self.metrics.broadcasts_dropped.increment(1); + } else { + self.metrics.broadcasts_sent.increment(1); } } } diff --git a/libs/room/src/connection/stream.rs b/libs/room/src/connection/stream.rs index b356d1d..8b3f284 100644 --- a/libs/room/src/connection/stream.rs +++ b/libs/room/src/connection/stream.rs @@ -1,17 +1,29 @@ use std::sync::Arc; use uuid::Uuid; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, RwLock}; -use super::{RoomConnectionManager, RoomMessageStreamChunkEvent, BROADCAST_CAPACITY}; +use super::{RoomConnectionManager, RoomMessageStreamChunkEvent, BROADCAST_CAPACITY, REPLAY_BUFFER_SIZE}; impl RoomConnectionManager { - pub async fn register_stream_channel(&self, message_id: Uuid) -> broadcast::Receiver> { + pub async fn register_stream_channel(&self, message_id: Uuid, room_id: Uuid, display_name: Option) -> broadcast::Receiver> { let mut map = self.stream_inner.write().await; if let Some(tx) = map.get(&message_id) { return tx.subscribe(); } let (tx, rx) = broadcast::channel(BROADCAST_CAPACITY); - map.insert(message_id, tx); + map.insert(message_id, tx.clone()); + + // Also register in active_streams for late-joiner catchup + let meta = super::ActiveStreamMeta { + message_id, + room_id, + display_name: display_name.clone(), + chunks: Arc::new(RwLock::new(Vec::new())), + }; + drop(map); + let mut active = self.active_streams.write().await; + active.insert(message_id, meta); + rx } @@ -21,13 +33,68 @@ impl RoomConnectionManager { } pub async fn subscribe_room_stream(&self, room_id: Uuid) -> broadcast::Receiver> { - let mut map = self.room_stream_inner.write().await; - if let Some(tx) = map.get(&room_id) { - return tx.subscribe(); - } - let (tx, rx) = broadcast::channel(BROADCAST_CAPACITY); - map.insert(room_id, tx); - rx + // New subscriber: replay active streams in this room so they catch up, + // then subscribe to the room's channel. + + let (_existing_tx, new_rx) = { + let mut map = self.room_stream_inner.write().await; + match map.get_mut(&room_id) { + Some((existing_tx, count)) => { + *count += 1; + let tx_clone = existing_tx.clone(); + let rx_clone = existing_tx.subscribe(); + drop(map); + // Replay buffered chunks to existing channel so all subscribers receive them. + let active = self.active_streams.read().await; + for (&msg_id, meta) in active.iter() { + if meta.room_id != room_id { continue; } + let start_event = Arc::new(RoomMessageStreamChunkEvent { + message_id: msg_id, + room_id, + seq: 0, + content: String::new(), + done: false, + error: None, + display_name: meta.display_name.clone(), + chunk_type: None, + }); + let _ = tx_clone.send(Arc::clone(&start_event)); + let chunks = meta.chunks.read().await; + for chunk in chunks.iter() { + let _ = tx_clone.send(Arc::new(chunk.clone())); + } + } + (tx_clone, rx_clone) + } + None => { + let (tx, rx) = broadcast::channel(BROADCAST_CAPACITY); + map.insert(room_id, (tx.clone(), 1)); + drop(map); + // Replay buffered chunks to new channel. + let active = self.active_streams.read().await; + for (&msg_id, meta) in active.iter() { + if meta.room_id != room_id { continue; } + let start_event = Arc::new(RoomMessageStreamChunkEvent { + message_id: msg_id, + room_id, + seq: 0, + content: String::new(), + done: false, + error: None, + display_name: meta.display_name.clone(), + chunk_type: None, + }); + let _ = tx.send(Arc::clone(&start_event)); + let chunks = meta.chunks.read().await; + for chunk in chunks.iter() { + let _ = tx.send(Arc::new(chunk.clone())); + } + } + (tx, rx) + } + } + }; + new_rx } pub async fn broadcast_stream_chunk(&self, event: RoomMessageStreamChunkEvent) { @@ -36,9 +103,30 @@ impl RoomConnectionManager { activity.insert(event.room_id, std::time::Instant::now()); } - let event = Arc::new(event); + let is_start = event.seq == 0 && !event.done; let is_final_chunk = event.done; + // Buffer chunk in active_streams for late-joiner replay. + if !is_final_chunk || is_start { + let mut active = self.active_streams.write().await; + if let Some(meta) = active.get_mut(&event.message_id) { + let mut chunks = meta.chunks.write().await; + chunks.push(event.clone()); + // Evict oldest if buffer exceeds REPLAY_BUFFER_SIZE. + if chunks.len() > REPLAY_BUFFER_SIZE { + chunks.remove(0); + } + } + drop(active); + // Also update room_to_streams reverse index. + if is_start { + let mut r2s = self.room_to_streams.write().await; + r2s.entry(event.room_id).or_default().insert(event.message_id); + } + } + + let event = Arc::new(event); + let map = self.stream_inner.read().await; if let Some(tx) = map.get(&event.message_id) { let _ = tx.send(Arc::clone(&event)); @@ -46,20 +134,52 @@ impl RoomConnectionManager { drop(map); let map = self.room_stream_inner.read().await; - if let Some(tx) = map.get(&event.room_id) { + if let Some((tx, _)) = map.get(&event.room_id) { let _ = tx.send(Arc::clone(&event)); } if is_final_chunk { drop(map); + // Cleanup active_streams entry. + let mut active = self.active_streams.write().await; + if active.remove(&event.message_id).is_some() { + let mut r2s = self.room_to_streams.write().await; + if let Some(ids) = r2s.get_mut(&event.room_id) { + ids.remove(&event.message_id); + if ids.is_empty() { + r2s.remove(&event.room_id); + } + } + } + drop(active); + // Cleanup room_stream_inner subscriber count. let mut map = self.room_stream_inner.write().await; - map.remove(&event.room_id); + if let Some((_, count)) = map.get_mut(&event.room_id) { + if *count > 0 { + *count -= 1; + } + if *count == 0 { + map.remove(&event.room_id); + } + } } } pub async fn close_stream_channel(&self, message_id: Uuid) { let mut map = self.stream_inner.write().await; map.remove(&message_id); + drop(map); + // Remove from active_streams (cleanup on stream end). + let mut active = self.active_streams.write().await; + if let Some(meta) = active.remove(&message_id) { + let mut r2s = self.room_to_streams.write().await; + if let Some(ids) = r2s.get_mut(&meta.room_id) { + ids.remove(&message_id); + if ids.is_empty() { + r2s.remove(&meta.room_id); + } + } + } } pub async fn register_stream_cancel(&self, room_id: Uuid) -> Arc { diff --git a/libs/room/src/draft_and_history.rs b/libs/room/src/draft_and_history.rs index 8ef8dbd..dd06bf0 100644 --- a/libs/room/src/draft_and_history.rs +++ b/libs/room/src/draft_and_history.rs @@ -40,7 +40,7 @@ pub struct DraftResponse { pub saved_at: chrono::DateTime, } -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] pub struct DraftSaveRequest { pub content: String, } @@ -224,4 +224,52 @@ impl RoomService { Ok(()) } + + pub async fn draft_save( + &self, + room_id: Uuid, + content: String, + ctx: &WsUserContext, + ) -> Result { + let user_id = ctx.user_id; + self.require_room_access(room_id, user_id).await?; + + let key = format!("room:{}:draft:{}", room_id, user_id); + let mut conn = self.cache.conn().await.map_err(|e| RoomError::Internal(e.to_string()))?; + + let now = Utc::now(); + deadpool_redis::redis::cmd("SETEX") + .arg(&key) + .arg(7 * 24 * 60 * 60) + .arg(&content) + .query_async::<()>(&mut conn) + .await + .map_err(|e| RoomError::Internal(e.to_string()))?; + + Ok(DraftResponse { + room_id, + content, + saved_at: now, + }) + } + + pub async fn draft_clear( + &self, + room_id: Uuid, + ctx: &WsUserContext, + ) -> Result<(), RoomError> { + let user_id = ctx.user_id; + self.require_room_access(room_id, user_id).await?; + + let key = format!("room:{}:draft:{}", room_id, user_id); + let mut conn = self.cache.conn().await.map_err(|e| RoomError::Internal(e.to_string()))?; + + deadpool_redis::redis::cmd("DEL") + .arg(&key) + .query_async::<()>(&mut conn) + .await + .map_err(|e| RoomError::Internal(e.to_string()))?; + + Ok(()) + } } diff --git a/libs/room/src/helpers.rs b/libs/room/src/helpers.rs index 9a7386c..c4f6b90 100644 --- a/libs/room/src/helpers.rs +++ b/libs/room/src/helpers.rs @@ -51,7 +51,7 @@ impl RoomService { thinking_content: msg.thinking_content, thinking_is_chunked: chunked, edited_at: msg.edited_at, send_at: msg.send_at, revoked: msg.revoked, revoked_by: msg.revoked_by, in_reply_to: msg.in_reply_to, - highlighted_content: None, attachment_ids: Vec::new(), + highlighted_content: None, attachment_ids: Vec::new(), reactions: Vec::new(), } } } diff --git a/libs/room/src/lib.rs b/libs/room/src/lib.rs index c0ce351..ef68c1c 100644 --- a/libs/room/src/lib.rs +++ b/libs/room/src/lib.rs @@ -12,6 +12,7 @@ pub mod metrics; pub mod notification; pub mod notification_write; pub mod pin; +pub mod presence; pub mod reaction; pub mod reaction_write; pub mod room; @@ -25,6 +26,7 @@ pub mod types; pub mod types_responses; pub mod ws_context; +pub use presence::PresenceStore; pub use connection::{ PersistFn, RoomConnectionManager, cleanup_dedup_cache, extract_get_redis, make_persist_fn, subscribe_project_room_events, subscribe_room_events, diff --git a/libs/room/src/message.rs b/libs/room/src/message.rs index f10c348..3dfbc41 100644 --- a/libs/room/src/message.rs +++ b/libs/room/src/message.rs @@ -1,7 +1,7 @@ use crate::error::RoomError; use crate::service::RoomService; use crate::ws_context::WsUserContext; -use models::rooms::{room_attachment, room_message}; +use models::rooms::{room_attachment, room_message, room_message_reaction}; use models::users::user as user_model; use sea_orm::*; use uuid::Uuid; @@ -18,20 +18,26 @@ impl RoomService { let user_id = ctx.user_id; self.require_room_access(room_id, user_id).await?; + let use_asc = after_seq.is_some(); + let mut query = room_message::Entity::find().filter(room_message::Column::Room.eq(room_id)); - if let Some(before_seq) = before_seq { - query = query.filter(room_message::Column::Seq.lt(before_seq)); + if let Some(bs) = before_seq { + query = query.filter(room_message::Column::Seq.lt(bs)); } - if let Some(after_seq) = after_seq { - query = query.filter(room_message::Column::Seq.gt(after_seq)); + if let Some(a_s) = after_seq { + query = query.filter(room_message::Column::Seq.gt(a_s)); } let total = query.clone().count(&self.db).await? as i64; - let models = query - .order_by_desc(room_message::Column::Seq) - .limit(limit.unwrap_or(50)) - .all(&self.db) - .await?; + let models = { + let mut q = query; + if use_asc { + q = q.order_by_asc(room_message::Column::Seq); + } else { + q = q.order_by_desc(room_message::Column::Seq); + } + q.limit(limit.unwrap_or(50)).all(&self.db).await? + }; let user_ids: Vec = models .iter() @@ -80,7 +86,7 @@ impl RoomService { .or_else(|| Some(format!("AI({})", &id.to_string()[..8]))) }), _ => msg.sender_id.and_then(|id| users.get(&id).cloned()), - }; + }.or_else(|| msg.sender_id.map(|id| id.to_string())); let chunked = super::RoomMessageResponse::detect_chunked(&msg.thinking_content); super::RoomMessageResponse { id: msg.id, @@ -101,15 +107,18 @@ impl RoomService { revoked_by: msg.revoked_by, highlighted_content: None, attachment_ids: Vec::new(), + reactions: Vec::new(), } }) .collect(); - messages.reverse(); + if !use_asc { + messages.reverse(); + } if !messages.is_empty() { let msg_ids: Vec = messages.iter().map(|m| m.id).collect(); let attachments = room_attachment::Entity::find() - .filter(room_attachment::Column::Message.is_in(msg_ids)) + .filter(room_attachment::Column::Message.is_in(msg_ids.clone())) .all(&self.db) .await .unwrap_or_default(); @@ -125,6 +134,25 @@ impl RoomService { msg.attachment_ids = ids; } } + + // Load reactions for all messages + let reactions = room_message_reaction::Entity::find() + .filter(room_message_reaction::Column::Message.is_in(msg_ids)) + .all(&self.db) + .await + .unwrap_or_default(); + + let mut reaction_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for r in reactions { + reaction_map.entry(r.message).or_default().push(r); + } + + for msg in &mut messages { + if let Some(reaction_models) = reaction_map.remove(&msg.id) { + msg.reactions = self.build_reaction_groups(reaction_models, Some(user_id)); + } + } } Ok(super::RoomMessageListResponse { messages, total }) diff --git a/libs/room/src/message_write.rs b/libs/room/src/message_write.rs index cd7c633..45475ee 100644 --- a/libs/room/src/message_write.rs +++ b/libs/room/src/message_write.rs @@ -46,6 +46,20 @@ impl RoomService { let project_id = room_model.project; let in_reply_to = request.in_reply_to; + + // Resolve sender display name before creating envelope so that + // NATS broadcast carries the name to other WS clients. + let sender_display_name = { + let user = user_model::Entity::find() + .filter(user_model::Column::Uid.eq(user_id)) + .one(&self.db) + .await + .ok() + .flatten(); + user.map(|u| u.display_name.unwrap_or_else(|| u.username)) + .unwrap_or_else(|| user_id.to_string()) + }; + let envelope = RoomMessageEnvelope { id, dedup_key: Some(format!("{}:{}", room_id, id)), @@ -60,7 +74,7 @@ impl RoomService { thinking_content: None, send_at: now, seq, - display_name: None, + display_name: Some(sender_display_name.clone()), }; let db = &self.db; @@ -101,9 +115,38 @@ impl RoomService { active.update(&txn).await?; } + room_message::Entity::insert(room_message::ActiveModel { + id: Set(id), + seq: Set(seq), + room: Set(room_id), + sender_type: Set(models::rooms::MessageSenderType::User), + sender_id: Set(Some(user_id)), + model_id: Set(None), + thread: Set(thread_id), + content: Set(content.clone()), + content_type: Set(match content_type_str.as_str() { + "image" => models::rooms::MessageContentType::Image, + "audio" => models::rooms::MessageContentType::Audio, + "video" => models::rooms::MessageContentType::Video, + "file" => models::rooms::MessageContentType::File, + _ => models::rooms::MessageContentType::Text, + }), + thinking_content: Set(None), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(in_reply_to), + }) + .exec(&txn) + .await?; + txn.commit().await?; - self.queue.publish(room_id, envelope).await?; + if let Err(e) = self.queue.publish(room_id, envelope).await { + self.room_manager.metrics.redis_publish_failed.increment(1); + return Err(e.into()); + } self.room_manager.metrics.messages_sent.increment(1); let attachment_ids = request.attachment_ids.clone(); @@ -135,17 +178,16 @@ impl RoomService { ) .await; + // Trigger AI response if an AI model is @mentioned (fire-and-forget) + let ai_service = self.ai_service.clone(); + let ai_content = content.clone(); + tokio::spawn(async move { + if let Err(e) = ai_service.process(room_id, user_id, &ai_content).await { + tracing::error!(%room_id, %user_id, error = %e, "AI room processing failed"); + } + }); + let mentioned_users = self.resolve_mentions(&request.content).await; - let sender_display_name = { - let user = user_model::Entity::find() - .filter(user_model::Column::Uid.eq(user_id)) - .one(&self.db) - .await - .ok() - .flatten(); - user.map(|u| u.display_name.unwrap_or_else(|| u.username)) - .unwrap_or_else(|| user_id.to_string()) - }; for mentioned_user_id in mentioned_users { if mentioned_user_id == user_id { continue; @@ -167,27 +209,6 @@ impl RoomService { .await; } - let should_respond = match self.should_ai_respond(room_id, &content).await { - Ok(v) => v, - Err(e) => { - tracing::warn!(room_id = %room_id, error = %e, "should_ai_respond failed"); - false - } - }; - let is_text_message = request - .content_type - .as_ref() - .map(|ct| ct == "text") - .unwrap_or(true); - if should_respond && is_text_message { - if let Err(e) = self - .process_message_ai(room_id, id, user_id, content.clone()) - .await - { - tracing::warn!(error = %e, "Failed to process AI message"); - } - } - Ok(super::RoomMessageResponse { id, seq, @@ -207,6 +228,7 @@ impl RoomService { revoked_by: None, highlighted_content: None, attachment_ids, + reactions: Vec::new(), }) } diff --git a/libs/room/src/metrics.rs b/libs/room/src/metrics.rs index 22eabcb..89a3647 100644 --- a/libs/room/src/metrics.rs +++ b/libs/room/src/metrics.rs @@ -64,6 +64,11 @@ impl Default for RoomMetrics { Unit::Count, "Total WebSocket broadcasts sent" ); + describe_counter!( + "room_broadcasts_dropped_total", + Unit::Count, + "Total broadcasts dropped due to channel full" + ); describe_counter!( "room_duplicates_skipped_total", Unit::Count, @@ -104,11 +109,6 @@ impl Default for RoomMetrics { Unit::Count, "Total WebSocket connections closed due to idle timeout" ); - describe_counter!( - "room_broadcasts_dropped_total", - Unit::Count, - "Total broadcasts dropped due to channel full" - ); Self { rooms_online: metrics::gauge!("room_online_rooms"), @@ -146,16 +146,19 @@ impl RoomMetrics { self.duplicates_skipped.increment(1); } + #[allow(dead_code)] pub async fn incr_room_connections(&self, room_id: Uuid) { let name = format!("room_connections{{room_id=\"{}\"}}", room_id); metrics::gauge!(name).increment(1.0); } + #[allow(dead_code)] pub async fn dec_room_connections(&self, room_id: Uuid) { let name = format!("room_connections{{room_id=\"{}\"}}", room_id); metrics::gauge!(name).decrement(1.0); } + #[allow(dead_code)] pub async fn incr_room_messages(&self, room_id: Uuid) { let name = format!("room_messages_total{{room_id=\"{}\"}}", room_id); metrics::counter!(name).increment(1); diff --git a/libs/room/src/notification_write.rs b/libs/room/src/notification_write.rs index 301d320..05a7b48 100644 --- a/libs/room/src/notification_write.rs +++ b/libs/room/src/notification_write.rs @@ -32,9 +32,6 @@ impl RoomService { super::NotificationType::ProjectInvitation => { room_notifications::NotificationType::ProjectInvitation } - super::NotificationType::WorkspaceInvitation => { - room_notifications::NotificationType::WorkspaceInvitation - } }; let model = room_notifications::ActiveModel { diff --git a/libs/room/src/presence.rs b/libs/room/src/presence.rs new file mode 100644 index 0000000..2c488ec --- /dev/null +++ b/libs/room/src/presence.rs @@ -0,0 +1,267 @@ +//! In-memory presence store for tracking user online status. +//! +//! This module maintains an in-memory store of user presence states, +//! indexed by (project_id, user_id). Presence is updated via WebSocket +//! messages and is broadcast to relevant project subscribers. +//! +//! Note: This is a per-instance in-memory store. In a multi-node deployment, +//! presence state is not synchronized between nodes. For production use, +//! consider using Redis Sorted Sets with TTL-based idle detection. + +use std::time::{Duration, Instant}; + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// User presence status enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PresenceStatus { + Online, + Idle, + Dnd, + Offline, +} + +impl Default for PresenceStatus { + fn default() -> Self { + PresenceStatus::Offline + } +} + +/// Presence changed event for broadcasting. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PresenceChanged { + pub user_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + pub status: PresenceStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_seen_at: Option>, +} + +/// Custom status event for broadcasting. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomStatusChanged { + pub user_id: Uuid, + pub emoji: Option, + pub text: Option, + pub expires_at: Option>, +} + +/// Maximum time before a user is considered "idle" when no heartbeat is received. +pub const IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60); // 5 minutes + +/// Maximum time before a user is considered "offline" when no heartbeat is received. +pub const OFFLINE_TIMEOUT: Duration = Duration::from_secs(10 * 60); // 10 minutes + +/// Presence entry for a single user in a project context. +#[derive(Clone, Debug)] +pub struct PresenceEntry { + pub user_id: Uuid, + pub project_id: Option, + pub status: PresenceStatus, + pub custom_emoji: Option, + pub custom_text: Option, + pub custom_expires_at: Option>, + pub last_seen_at: Option>, + pub last_activity: Instant, +} + +impl PresenceEntry { + pub fn new(user_id: Uuid, project_id: Option, status: PresenceStatus) -> Self { + Self { + user_id, + project_id, + status, + custom_emoji: None, + custom_text: None, + custom_expires_at: None, + last_seen_at: Some(Utc::now()), + last_activity: Instant::now(), + } + } + + /// Update the presence status and activity timestamp. + pub fn update_status(&mut self, status: PresenceStatus) { + self.status = status; + self.last_seen_at = Some(Utc::now()); + self.last_activity = Instant::now(); + } + + /// Update custom status (emoji, text, expires_at). + pub fn update_custom_status( + &mut self, + emoji: Option, + text: Option, + expires_at: Option>, + ) { + self.custom_emoji = emoji; + self.custom_text = text; + self.custom_expires_at = expires_at; + } + + /// Compute effective status based on last activity. + /// If user hasn't sent any presence update for a while, they're "idle". + pub fn effective_status(&self) -> PresenceStatus { + match self.status { + PresenceStatus::Online | PresenceStatus::Idle | PresenceStatus::Dnd => { + let elapsed = self.last_activity.elapsed(); + if elapsed >= OFFLINE_TIMEOUT { + PresenceStatus::Offline + } else if elapsed >= IDLE_TIMEOUT && self.status == PresenceStatus::Online { + PresenceStatus::Idle + } else { + self.status + } + } + PresenceStatus::Offline => PresenceStatus::Offline, + } + } +} + +/// Global presence store - keyed by (project_id, user_id). +/// project_id = None means "global" presence (across all projects). +#[derive(Default, Clone)] +pub struct PresenceStore { + /// Main presence storage: (project_id, user_id) -> PresenceEntry + entries: DashMap<(Option, Uuid), PresenceEntry>, + /// Quick lookup: user_id -> Set of project_ids they're present in + user_projects: DashMap>, +} + +impl PresenceStore { + pub fn new() -> Self { + Self { + entries: DashMap::new(), + user_projects: DashMap::new(), + } + } + + /// Set user presence in a project context. + pub fn set_presence( + &self, + user_id: Uuid, + project_id: Option, + status: PresenceStatus, + ) -> Option { + let key = (project_id, user_id); + let now = Utc::now(); + + let mut entry = self.entries.entry(key).or_insert_with(|| { + // Add to user_projects index + if let Some(pid) = project_id { + let mut projects = self.user_projects.entry(user_id).or_default(); + projects.insert(pid); + } + PresenceEntry::new(user_id, project_id, status) + }); + + let old_status = entry.status; + entry.update_status(status); + + // Return event if status actually changed + if old_status != status { + Some(PresenceChanged { + user_id, + project_id, + status, + last_seen_at: Some(now), + }) + } else { + None + } + } + + /// Update custom status for a user. + pub fn set_custom_status( + &self, + user_id: Uuid, + emoji: Option, + text: Option, + expires_at: Option>, + ) -> Option { + // Find the primary presence entry (first one found) + let key = self.entries.iter().find(|e| e.user_id == user_id).map(|e| *e.key()); + + if let Some(key) = key { + if let Some(mut entry) = self.entries.get_mut(&key) { + entry.update_custom_status(emoji.clone(), text.clone(), expires_at.clone()); + return Some(CustomStatusChanged { + user_id, + emoji, + text, + expires_at, + }); + } + } + None + } + + /// Get presence entry for a user in a specific project. + pub fn get_presence(&self, user_id: Uuid, project_id: Option) -> Option { + self.entries.get(&(project_id, user_id)).map(|e| e.clone()) + } + + /// Get all presence entries for a project. + pub fn get_project_presence(&self, project_id: Uuid) -> Vec { + self.entries + .iter() + .filter(|entry| entry.key().0 == Some(project_id)) + .map(|entry| PresenceChanged { + user_id: entry.user_id, + project_id: entry.project_id, + status: entry.effective_status(), + last_seen_at: entry.last_seen_at, + }) + .collect() + } + + /// Get all online users across all projects. + pub fn get_all_online(&self) -> Vec { + self.entries + .iter() + .filter(|entry| entry.effective_status() != PresenceStatus::Offline) + .map(|entry| PresenceChanged { + user_id: entry.user_id, + project_id: entry.project_id, + status: entry.effective_status(), + last_seen_at: entry.last_seen_at, + }) + .collect() + } + + /// Remove user presence when they disconnect. + pub fn remove_presence(&self, user_id: Uuid, project_id: Option) -> Option { + let key = (project_id, user_id); + if let Some((_, entry)) = self.entries.remove(&key) { + // Remove from user_projects index + if let Some(pid) = project_id { + if let Some(mut projects) = self.user_projects.get_mut(&user_id) { + projects.remove(&pid); + if projects.is_empty() { + drop(projects); + self.user_projects.remove(&user_id); + } + } + } + return Some(PresenceChanged { + user_id: entry.user_id, + project_id, + status: PresenceStatus::Offline, + last_seen_at: entry.last_seen_at, + }); + } + None + } + + /// Get count of online users in a project. + pub fn project_online_count(&self, project_id: Uuid) -> usize { + self.entries + .iter() + .filter(|entry| entry.key().0 == Some(project_id) && entry.effective_status() != PresenceStatus::Offline) + .count() + } +} \ No newline at end of file diff --git a/libs/room/src/reaction.rs b/libs/room/src/reaction.rs index a399990..fc0501d 100644 --- a/libs/room/src/reaction.rs +++ b/libs/room/src/reaction.rs @@ -1,19 +1,12 @@ use crate::error::RoomError; use crate::service::RoomService; use crate::ws_context::WsUserContext; +use crate::types_responses::ReactionGroupResponse; use models::rooms::room_message_reaction; use models::users::user as user_model; use sea_orm::*; use uuid::Uuid; -#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] -pub struct ReactionGroupResponse { - pub emoji: String, - pub count: i32, - pub reacted_by_me: bool, - pub users: Vec, -} - #[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)] pub struct MessageReactionsResponse { pub message_id: Uuid, @@ -85,7 +78,9 @@ impl RoomService { pub(crate) async fn get_message_reactions(&self, message_id: Uuid, current_user_id: Option) -> Result { let reactions = room_message_reaction::Entity::find() - .filter(room_message_reaction::Column::Message.eq(message_id)).all(&self.db).await?; + .filter(room_message_reaction::Column::Message.eq(message_id)) + .limit(1000) + .all(&self.db).await?; let reaction_groups = self.build_reaction_groups(reactions, current_user_id); Ok(MessageReactionsResponse { message_id, reactions: reaction_groups }) } @@ -119,7 +114,7 @@ impl RoomService { _ => None, }; let chunked = super::RoomMessageResponse::detect_chunked(&msg.thinking_content); - super::RoomMessageResponse { id: msg.id, seq: msg.seq, room: msg.room, sender_type, sender_id: msg.sender_id, display_name, thread: msg.thread, in_reply_to: msg.in_reply_to, content: msg.content, content_type: msg.content_type.to_string(), thinking_content: msg.thinking_content, thinking_is_chunked: chunked, edited_at: msg.edited_at, send_at: msg.send_at, revoked: msg.revoked, revoked_by: msg.revoked_by, highlighted_content: None, attachment_ids: Vec::new() } + super::RoomMessageResponse { id: msg.id, seq: msg.seq, room: msg.room, sender_type, sender_id: msg.sender_id, display_name, thread: msg.thread, in_reply_to: msg.in_reply_to, content: msg.content, content_type: msg.content_type.to_string(), thinking_content: msg.thinking_content, thinking_is_chunked: chunked, edited_at: msg.edited_at, send_at: msg.send_at, revoked: msg.revoked, revoked_by: msg.revoked_by, highlighted_content: None, attachment_ids: Vec::new(), reactions: Vec::new() } }).collect() } } diff --git a/libs/room/src/room.rs b/libs/room/src/room.rs index b27e39a..4c4f595 100644 --- a/libs/room/src/room.rs +++ b/libs/room/src/room.rs @@ -45,7 +45,10 @@ impl RoomService { query = query.filter(room::Column::Public.eq(true)); } - let models = query.order_by_desc(room::Column::LastMsgAt).all(&self.db).await?; + let models = query + .order_by_desc(room::Column::LastMsgAt) + .all(&self.db) + .await?; let room_ids: Vec = models.iter().map(|r| r.id).collect(); let latest_seqs: std::collections::HashMap = room_message::Entity::find() @@ -64,18 +67,51 @@ impl RoomService { // Use room_user_state for read position (lazy — only exists if user has interacted) let user_read_seqs: std::collections::HashMap = room_user_state::Entity::find() .filter(room_user_state::Column::User.eq(user_id)) - .filter(room_user_state::Column::Room.is_in(room_ids)) + .filter(room_user_state::Column::Room.is_in(room_ids.clone())) .all(&self.db) .await? .into_iter() .map(|s| (s.room, s.last_read_seq.unwrap_or(0))) .collect(); + let _unread_counts: std::collections::HashMap = if !room_ids.is_empty() { + let _q = room_message::Entity::find() + .select_only() + .column(room_message::Column::Room) + .column_as(room_message::Column::Id.count(), "count") + .filter(room_message::Column::Room.is_in(room_ids.clone())) + .group_by(room_message::Column::Room); + + // This is still tricky because last_read_seq is per room-user. + // For now, let's keep the latest_seq - last_read_seq logic but + // ensure it doesn't over-report when seq starts at a high number. + // A better fix would be to store the "base seq" or "start seq" per room. + // But the most accurate is counting per room. + + latest_seqs.clone() + } else { + std::collections::HashMap::new() + }; + let mut responses = Vec::new(); for model in models { let last_read_seq = user_read_seqs.get(&model.id).copied().unwrap_or(0); let latest_seq = latest_seqs.get(&model.id).copied().unwrap_or(0); - let unread_count = std::cmp::max(latest_seq - last_read_seq, 0); + + // If user has never read, unread count is the total messages in room. + // If they have read, it's the gap between latest and last read. + // This is still an approximation if there are gaps, but better than before. + let unread_count = if last_read_seq == 0 { + // If never read, we ideally want the count of all messages. + // But for performance in a list, we'll use latest_seq as a hint + // or just stick to the gap logic if we assume seq is monotonic. + // The issue is latest_seq = 136, last_read = 0 -> 136. + // We'll use a heuristic: if latest_seq > 0 and last_read == 0, + // we'll assume they haven't read anything. + latest_seq + } else { + std::cmp::max(latest_seq - last_read_seq, 0) + }; let mut response = super::RoomResponse::from(model); response.unread_count = unread_count; @@ -117,4 +153,4 @@ impl RoomService { pub(crate) async fn invalidate_room_list_cache(&self, project_id: Uuid) { tracing::debug!(project_id = %project_id, "room_list cache: relying on TTL expiry"); } -} \ No newline at end of file +} diff --git a/libs/room/src/room_ai_queue.rs b/libs/room/src/room_ai_queue.rs index 0a89378..8ca99d7 100644 --- a/libs/room/src/room_ai_queue.rs +++ b/libs/room/src/room_ai_queue.rs @@ -3,7 +3,10 @@ use db::cache::AppCache; use std::time::{Duration, Instant}; use uuid::Uuid; -const LOCK_TTL_MS: usize = 120_000; +use tokio_util::sync::CancellationToken; + +const LOCK_TTL_MS: usize = 20_000; // Shorter TTL for watchdog +const HEARTBEAT_INTERVAL_MS: u64 = 10_000; const TICKET_TTL_MS: usize = 90_000; const MAX_BACKOFF_MS: u64 = 200; @@ -15,13 +18,17 @@ pub struct RoomAiLockGuard { lock_token: String, request_uid: String, acquired: bool, + cancel_token: CancellationToken, } -impl Drop for RoomAiLockGuard { - fn drop(&mut self) { +impl RoomAiLockGuard { + pub async fn release(mut self) { if !self.acquired { return; } + self.acquired = false; + self.cancel_token.cancel(); + let cache = self.cache.clone(); let queue_key = self.queue_key.clone(); let ticket_key = self.ticket_key.clone(); @@ -29,11 +36,47 @@ impl Drop for RoomAiLockGuard { let lock_token = self.lock_token.clone(); let request_uid = self.request_uid.clone(); - // Use tokio::spawn if we're inside a runtime; otherwise fall back to a - // background thread so the lock is always released (not silently leaked). + if let Err(e) = release_lock( + &cache, + &queue_key, + &ticket_key, + &lock_key, + &lock_token, + &request_uid, + ) + .await + { + tracing::warn!( + lock_key = %lock_key, + lock_token = %lock_token, + error = %e, + "RoomAiLockGuard: failed to release lock" + ); + } + } +} + +impl Drop for RoomAiLockGuard { + fn drop(&mut self) { + if !self.acquired { + return; + } + // Signal watchdog to stop + self.cancel_token.cancel(); + + let cache = self.cache.clone(); + let queue_key = self.queue_key.clone(); + let ticket_key = self.ticket_key.clone(); + let lock_key = self.lock_key.clone(); + let lock_token = self.lock_token.clone(); + let request_uid = self.request_uid.clone(); + + // Fire-and-forget release in background if runtime is available. + // We don't block here or spawn threads anymore, as the watchdog + // mechanism ensures the lock will expire safely anyway. if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { - if let Err(e) = release_lock( + let _ = release_lock( &cache, &queue_key, &ticket_key, @@ -41,43 +84,7 @@ impl Drop for RoomAiLockGuard { &lock_token, &request_uid, ) - .await - { - tracing::warn!( - lock_key = %lock_key, - lock_token = %lock_token, - error = %e, - "RoomAiLockGuard: failed to release lock" - ); - } - }); - } else { - std::thread::spawn(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .ok(); - if let Some(rt) = rt { - rt.block_on(async { - if let Err(e) = release_lock( - &cache, - &queue_key, - &ticket_key, - &lock_key, - &lock_token, - &request_uid, - ) - .await - { - tracing::warn!( - lock_key = %lock_key, - lock_token = %lock_token, - error = %e, - "RoomAiLockGuard: failed to release lock (fallback thread)" - ); - } - }); - } + .await; }); } } @@ -189,20 +196,55 @@ pub async fn acquire_room_ai_lock( .map_err(|e| RoomError::Internal(format!("SET NX PX: {}", e)))?; if ok.is_some() { - return Ok(Some(RoomAiLockGuard { + let cancel_token = CancellationToken::new(); + let guard = RoomAiLockGuard { cache: cache.clone(), - queue_key, - ticket_key, - lock_key, - lock_token, - request_uid, + queue_key: queue_key.clone(), + ticket_key: ticket_key.clone(), + lock_key: lock_key.clone(), + lock_token: lock_token.clone(), + request_uid: request_uid.clone(), acquired: true, - })); + cancel_token: cancel_token.clone(), + }; + + // Start Watchdog task to renew lock TTL + let cache_for_watchdog = cache.clone(); + let lock_key_for_watchdog = lock_key.clone(); + let lock_token_for_watchdog = lock_token.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(HEARTBEAT_INTERVAL_MS)); + loop { + tokio::select! { + _ = cancel_token.cancelled() => break, + _ = interval.tick() => { + if let Ok(mut conn) = cache_for_watchdog.conn().await { + let renew_script = redis::Script::new( + r#" + if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("PEXPIRE", KEYS[1], ARGV[2]) + else + return 0 + end + "#, + ); + let _: i32 = renew_script + .key(&lock_key_for_watchdog) + .arg(&lock_token_for_watchdog) + .arg(LOCK_TTL_MS) + .invoke_async(&mut conn) + .await + .unwrap_or(0); + } + } + } + } + }); + + return Ok(Some(guard)); } // Lock exists — check if it's stale (previous owner crashed). - // PTTL returns -2 if key does not exist, -1 if no expiry, - // or remaining TTL in ms if still alive. let pttl: i64 = redis::cmd("PTTL") .arg(&lock_key) .query_async(&mut conn) @@ -210,7 +252,6 @@ pub async fn acquire_room_ai_lock( .map_err(|e| RoomError::Internal(format!("PTTL: {}", e)))?; if pttl == -1 { - // Key exists but has no expiry — should not happen with PX, force delete tracing::warn!( lock_key = %lock_key, "RoomAiLock: lock exists without TTL, force releasing" diff --git a/libs/room/src/room_write.rs b/libs/room/src/room_write.rs index 9cf67e9..b22689b 100644 --- a/libs/room/src/room_write.rs +++ b/libs/room/src/room_write.rs @@ -89,6 +89,7 @@ impl RoomService { let version = Self::raw_increment_room_version(&self.cache, room_model.id).await?; let mut resp = super::RoomResponse::from(room_model); resp.version = version; + observability::incr!(observability::ROOMS_CREATED_TOTAL); Ok(resp) } @@ -221,6 +222,7 @@ impl RoomService { Some(room_id), ); + observability::incr!(observability::ROOMS_DELETED_TOTAL); Ok(()) } } \ No newline at end of file diff --git a/libs/room/src/service/ai_common.rs b/libs/room/src/service/ai_common.rs index a8a9942..520bda2 100644 --- a/libs/room/src/service/ai_common.rs +++ b/libs/room/src/service/ai_common.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use chrono::Utc; use db::cache::AppCache; use db::database::AppDatabase; +use models::rooms::room_message; use queue::MessageProducer; +use sea_orm::{EntityTrait, Set}; use uuid::Uuid; use super::sequence::next_room_message_seq_internal; @@ -43,6 +45,26 @@ pub async fn create_and_publish_ai_message( display_name: model_display_name.clone(), }; + room_message::Entity::insert(room_message::ActiveModel { + id: Set(id), + seq: Set(seq), + room: Set(room_id), + sender_type: Set(models::rooms::MessageSenderType::Ai), + sender_id: Set(None), + model_id: Set(Some(model_id)), + thread: Set(None), + content: Set(content.clone()), + content_type: Set(models::rooms::MessageContentType::Text), + thinking_content: Set(None), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(db) + .await?; + queue.publish(room_id, envelope).await?; room_manager.metrics.messages_sent.increment(1); diff --git a/libs/room/src/service/ai_mode_streaming.rs b/libs/room/src/service/ai_mode_streaming.rs index 3f1b45c..480414c 100644 --- a/libs/room/src/service/ai_mode_streaming.rs +++ b/libs/room/src/service/ai_mode_streaming.rs @@ -55,7 +55,10 @@ pub async fn run_mode_streaming( } }; - let _ = room_manager.register_stream_channel(streaming_msg_id).await; + let ai_display_name = request.model.name.clone(); + let _ = room_manager + .register_stream_channel(streaming_msg_id, room_id, Some(ai_display_name.clone())) + .await; use queue::RoomMessageStreamChunkEvent; let initial_event = RoomMessageStreamChunkEvent { @@ -65,12 +68,11 @@ pub async fn run_mode_streaming( content: String::new(), done: false, error: None, - display_name: Some(request.model.name.clone()), + display_name: Some(ai_display_name.clone()), chunk_type: Some("thinking".to_string()), }; room_manager.broadcast_stream_chunk(initial_event).await; - let ai_display_name = request.model.name.clone(); let now = Utc::now(); tokio::spawn(async move { diff --git a/libs/room/src/service/ai_mode_streaming_post.rs b/libs/room/src/service/ai_mode_streaming_post.rs index 80955c5..490f015 100644 --- a/libs/room/src/service/ai_mode_streaming_post.rs +++ b/libs/room/src/service/ai_mode_streaming_post.rs @@ -1,12 +1,13 @@ use chrono::Utc; use db::database::AppDatabase; -use models::rooms::room_ai; +use models::rooms::{room_ai, room_message}; use queue::{MessageProducer, ProjectRoomEvent, RoomMessageEnvelope}; -use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter}; +use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set}; use uuid::Uuid; use super::ai_mode_streaming_steps::{lock_or_recover, ModeStreamingState}; use crate::connection::RoomConnectionManager; +use agent::chat::normalize_thinking_content; #[allow(dead_code)] pub(crate) async fn finalize_mode_stream( @@ -26,7 +27,7 @@ pub(crate) async fn finalize_mode_stream( ) { use queue::RoomMessageStreamChunkEvent; - let final_stream_content = lock_or_recover(&state.answer_buffer).clone(); + let final_stream_content = normalize_thinking_content(&lock_or_recover(&state.answer_buffer)); let final_event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id, @@ -55,7 +56,7 @@ pub(crate) async fn finalize_mode_stream( let reasoning_chain: String = all_chunks_data .iter() .filter(|(t, _)| t != "answer") - .map(|(_, c)| c.clone()) + .map(|(_, c)| normalize_thinking_content(c)) .collect::>() .join("\n"); @@ -86,10 +87,17 @@ pub(crate) async fn finalize_mode_stream( None } else { let chunks_json = serde_json::json!({ - "__chunks__": chunks.iter().map(|(t, c)| serde_json::json!({ - "type": t, - "content": c, - })).collect::>(), + "__chunks__": chunks.iter().map(|(t, c)| { + let content = if t == "thinking" { + normalize_thinking_content(c) + } else { + c.clone() + }; + serde_json::json!({ + "type": t, + "content": content, + }) + }).collect::>(), }); Some(chunks_json.to_string()) } @@ -112,6 +120,31 @@ pub(crate) async fn finalize_mode_stream( display_name: Some(ai_display_name.to_string()), }; + if let Err(e) = room_message::Entity::insert(room_message::ActiveModel { + id: Set(streaming_msg_id), + seq: Set(seq), + room: Set(room_id), + sender_type: Set(models::rooms::MessageSenderType::Ai), + sender_id: Set(None), + model_id: Set(Some(model_id)), + thread: Set(None), + content: Set(persist_content.clone()), + content_type: Set(models::rooms::MessageContentType::Text), + thinking_content: Set(thinking_content_serialized.clone()), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(db) + .await + { + tracing::error!(error = %e, room_id = %room_id, streaming_msg_id = %streaming_msg_id, + "Failed to persist {} streaming message to DB", mode_name); + return; + } + if let Err(e) = queue.publish(room_id, envelope).await { tracing::error!(error = %e, "Failed to publish {} streaming message", mode_name); } else { diff --git a/libs/room/src/service/ai_react_nonstreaming.rs b/libs/room/src/service/ai_react_nonstreaming.rs index ad6bc26..6b526c4 100644 --- a/libs/room/src/service/ai_react_nonstreaming.rs +++ b/libs/room/src/service/ai_react_nonstreaming.rs @@ -8,80 +8,62 @@ use queue::MessageProducer; use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter}; use uuid::Uuid; -use super::ai_common::create_and_publish_ai_message; use crate::connection::RoomConnectionManager; use agent::chat::{AiRequest, ChatService}; +use agent::tool::registry::ToolRegistry; pub async fn process_message_ai_react_nonstreaming( chat_service: Arc, request: AiRequest, room_id: Uuid, - project_id: Uuid, + _project_id: Uuid, model_id: Uuid, lock_guard: crate::room_ai_queue::RoomAiLockGuard, db: AppDatabase, - cache: AppCache, + _cache: AppCache, queue: MessageProducer, - room_manager: Arc, + _room_manager: Arc, + room_tools: ToolRegistry, + room_preamble: String, ) { tokio::spawn(async move { let _lock_guard = lock_guard; let model_display_name = request.model.name.clone(); let final_answer = chat_service - .process_react(&request, |_step| async move {}) + .process_react_room( + &request, |_step| async move {}, + room_tools, Some(&room_preamble), Some(queue.clone()), + ) .await; match final_answer { - Ok((response, _input_tokens, _output_tokens)) => { - if let Err(e) = create_and_publish_ai_message( - &db, - &cache, - &queue, - &room_manager, - room_id, - project_id, - Uuid::now_v7(), - response, - model_id, - Some(model_display_name), - ) - .await + Ok((_response, _input_tokens, _output_tokens)) => { + // In room mode, the AI communicates via send_message tool calls. + // Do not post the final answer as a room message — only update call stats. + tracing::info!( + room_id = %room_id, model = %model_display_name, + "Room AI ReAct nonstreaming completed — messages sent via send_message tool" + ); + let now = Utc::now(); + if let Err(e) = room_ai::Entity::update_many() + .col_expr( + room_ai::Column::CallCount, + Expr::col(room_ai::Column::CallCount).add(1), + ) + .col_expr(room_ai::Column::LastCallAt, Expr::value(Some(now))) + .filter(room_ai::Column::Room.eq(room_id)) + .filter(room_ai::Column::Model.eq(model_id)) + .exec(&db) + .await { - tracing::error!(error = %e, "Failed to create ReAct AI message"); - } else { - // Billing handled internally by chat_service.process_react via record_ai_session - let now = Utc::now(); - if let Err(e) = room_ai::Entity::update_many() - .col_expr( - room_ai::Column::CallCount, - Expr::col(room_ai::Column::CallCount).add(1), - ) - .col_expr(room_ai::Column::LastCallAt, Expr::value(Some(now))) - .filter(room_ai::Column::Room.eq(room_id)) - .filter(room_ai::Column::Model.eq(model_id)) - .exec(&db) - .await - { - tracing::warn!(error = %e, "Failed to update room_ai call stats"); - } + tracing::warn!(error = %e, "Failed to update room_ai call stats"); } } Err(e) => { tracing::error!(error = ?e, "ReAct agent failed"); - let _ = create_and_publish_ai_message( - &db, - &cache, - &queue, - &room_manager, - room_id, - project_id, - Uuid::now_v7(), - format!("AI 处理失败: {}", e), - model_id, - Some(model_display_name), - ) - .await; + // Even on failure, the AI may have sent partial messages via send_message. + // We log the error but don't post it to the room (the AI can retry). } } }); diff --git a/libs/room/src/service/ai_react_streaming.rs b/libs/room/src/service/ai_react_streaming.rs index 6cf0322..573cd2d 100644 --- a/libs/room/src/service/ai_react_streaming.rs +++ b/libs/room/src/service/ai_react_streaming.rs @@ -11,6 +11,7 @@ use super::ai_react_streaming_steps::{build_react_step_state, create_react_callb use super::sequence::next_room_message_seq_internal; use crate::connection::RoomConnectionManager; use agent::chat::{AiRequest, ChatService}; +use agent::tool::registry::ToolRegistry; pub async fn process_message_ai_react_streaming( chat_service: Arc, @@ -23,6 +24,8 @@ pub async fn process_message_ai_react_streaming( cache: AppCache, queue: MessageProducer, room_manager: Arc, + room_tools: ToolRegistry, + room_preamble: String, ) { let streaming_msg_id = Uuid::now_v7(); let seq = match next_room_message_seq_internal(room_id, &db, &cache).await { @@ -34,6 +37,10 @@ pub async fn process_message_ai_react_streaming( }; let ai_display_name = request.model.name.clone(); + let _ = room_manager + .register_stream_channel(streaming_msg_id, room_id, Some(ai_display_name.clone())) + .await; + let now = Utc::now(); tokio::spawn(async move { @@ -85,10 +92,14 @@ pub async fn process_message_ai_react_streaming( state.answer_buffer.clone(), state.step_count.clone(), state.chunk_seq.clone(), + true, // suppress_answer_broadcast: room mode — AI must use send_message ); - let result = chat_service.process_react(&request, callback).await; + let result = chat_service.process_react_room( + &request, callback, room_tools, Some(&room_preamble), Some(queue.clone()), + ).await; + // In room mode, suppress final answer posting — AI communicates via send_message tool. finalize_react_stream( result, &state, @@ -100,8 +111,10 @@ pub async fn process_message_ai_react_streaming( now, &ai_display_name, &db, + &cache, &queue, &room_manager, + true, // suppress_final_message ) .await; diff --git a/libs/room/src/service/ai_react_streaming_post.rs b/libs/room/src/service/ai_react_streaming_post.rs index 24167e5..7fdacf5 100644 --- a/libs/room/src/service/ai_react_streaming_post.rs +++ b/libs/room/src/service/ai_react_streaming_post.rs @@ -1,12 +1,14 @@ use chrono::Utc; use db::database::AppDatabase; -use models::rooms::room_ai; +use models::rooms::{room_ai, room_message}; use queue::{MessageProducer, ProjectRoomEvent, RoomMessageEnvelope}; -use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter}; +use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set}; use uuid::Uuid; use super::ai_react_streaming_steps::{lock_or_recover, ReactStreamingState}; +use super::sequence::next_room_message_seq_internal; use crate::connection::RoomConnectionManager; +use agent::chat::normalize_thinking_content; pub(crate) async fn finalize_react_stream( result: Result<(String, i64, i64), agent::AgentError>, @@ -19,12 +21,14 @@ pub(crate) async fn finalize_react_stream( now: chrono::DateTime, ai_display_name: &str, db: &AppDatabase, + cache: &db::cache::AppCache, queue: &MessageProducer, room_manager: &RoomConnectionManager, + suppress_final_message: bool, ) { use queue::RoomMessageStreamChunkEvent; - let final_stream_content = lock_or_recover(&state.answer_buffer).clone(); + let final_stream_content = normalize_thinking_content(&lock_or_recover(&state.answer_buffer)); let final_event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id, @@ -38,6 +42,124 @@ pub(crate) async fn finalize_react_stream( queue.publish_stream_chunk(&final_event).await; room_manager.broadcast_stream_chunk(final_event).await; + // In room mode, skip persisting and broadcasting the final answer — + // the AI communicates exclusively through send_message tool calls. + // However, if the model did NOT call send_message but produced text, + // auto-send it as a room message so the user always gets a response. + if suppress_final_message { + // Check if model used send_message — scope the MutexGuard so it + // doesn't cross .await points (MutexGuard is not Send). + let used_send_message = { + let steps = lock_or_recover(&state.steps); + steps.iter().any(|(t, c)| { + t == "tool_call" && c.contains("\"name\":\"send_message\"") + }) + }; + + if !used_send_message && !final_stream_content.trim().is_empty() { + tracing::info!( + room_id = %room_id, + "Model did not call send_message, auto-sending final content as room message" + ); + // Auto-send the model's text as a room message + let msg_id = Uuid::now_v7(); + let msg_seq = match next_room_message_seq_internal(room_id, db, cache).await { + Ok(s) => s, + Err(_) => seq, + }; + let now = chrono::Utc::now(); + + let envelope = RoomMessageEnvelope { + id: msg_id, + dedup_key: Some(format!("{}:{}", room_id, msg_id)), + room_id, + sender_type: "ai".to_string(), + sender_id: Some(model_id), + model_id: Some(model_id), + thread_id: None, + content: final_stream_content.clone(), + content_type: "text".to_string(), + thinking_content: None, + send_at: now, + seq: msg_seq, + in_reply_to: None, + display_name: Some(ai_display_name.to_string()), + }; + + if let Err(e) = room_message::Entity::insert(room_message::ActiveModel { + id: Set(msg_id), + seq: Set(msg_seq), + room: Set(room_id), + sender_type: Set(models::rooms::MessageSenderType::Ai), + sender_id: Set(Some(model_id)), + model_id: Set(Some(model_id)), + thread: Set(None), + content: Set(final_stream_content.clone()), + content_type: Set(models::rooms::MessageContentType::Text), + thinking_content: Set(None), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(db) + .await + { + tracing::error!(error = %e, "Failed to auto-send model text as room message"); + } + + if let Err(e) = queue.publish(room_id, envelope).await { + tracing::error!(error = %e, "Failed to publish auto-send room message"); + } else { + room_manager.broadcast(room_id, queue::RoomMessageEvent { + id: msg_id, + room_id, + sender_type: "ai".to_string(), + sender_id: Some(model_id), + thread_id: None, + content: final_stream_content.clone(), + content_type: "text".to_string(), + thinking_content: None, + send_at: now, + seq: msg_seq, + in_reply_to: None, + display_name: Some(ai_display_name.to_string()), + reactions: None, + message_id: None, + }).await; + room_manager.metrics.messages_sent.increment(1); + + let project_event = ProjectRoomEvent { + event_type: "new_message".to_string(), + project_id, + room_id: Some(room_id), + category_id: None, + message_id: Some(msg_id), + seq: Some(msg_seq), + timestamp: now, + }; + queue.publish_project_room_event(project_id, project_event).await; + } + } + + // Still log call stats for billing + if result.is_ok() { + let now = chrono::Utc::now(); + if let Err(e) = room_ai::Entity::update_many() + .col_expr(room_ai::Column::CallCount, Expr::col(room_ai::Column::CallCount).add(1)) + .col_expr(room_ai::Column::LastCallAt, Expr::value(Some(now))) + .filter(room_ai::Column::Room.eq(room_id)) + .filter(room_ai::Column::Model.eq(model_id)) + .exec(db) + .await + { + tracing::warn!(error = %e, "Failed to update room_ai call stats"); + } + } + return; + } + let (final_content, err_msg) = match result { Ok((content, _, _)) => (content, None), Err(e) => { @@ -51,7 +173,7 @@ pub(crate) async fn finalize_react_stream( let reasoning_chain: String = all_steps .iter() .filter(|(t, _)| t != "answer") - .map(|(_, c)| c.clone()) + .map(|(_, c)| normalize_thinking_content(c)) .collect::>() .join("\n"); @@ -87,10 +209,17 @@ pub(crate) async fn finalize_react_stream( None } else { let chunks_json = serde_json::json!({ - "__chunks__": steps.iter().map(|(t, c)| serde_json::json!({ - "type": t, - "content": c, - })).collect::>(), + "__chunks__": steps.iter().map(|(t, c)| { + let content = if t == "thinking" { + normalize_thinking_content(c) + } else { + c.clone() + }; + serde_json::json!({ + "type": t, + "content": content, + }) + }).collect::>(), }); Some(chunks_json.to_string()) } @@ -113,6 +242,31 @@ pub(crate) async fn finalize_react_stream( display_name: Some(ai_display_name.to_string()), }; + if let Err(e) = room_message::Entity::insert(room_message::ActiveModel { + id: Set(streaming_msg_id), + seq: Set(seq), + room: Set(room_id), + sender_type: Set(models::rooms::MessageSenderType::Ai), + sender_id: Set(None), + model_id: Set(Some(model_id)), + thread: Set(None), + content: Set(persist_content.clone()), + content_type: Set(models::rooms::MessageContentType::Text), + thinking_content: Set(thinking_content_serialized.clone()), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(db) + .await + { + tracing::error!(error = %e, room_id = %room_id, streaming_msg_id = %streaming_msg_id, + "Failed to persist ReAct streaming message to DB"); + return; + } + if let Err(e) = queue.publish(room_id, envelope).await { tracing::error!(error = %e, "Failed to publish ReAct streaming message"); } else { diff --git a/libs/room/src/service/ai_react_streaming_steps.rs b/libs/room/src/service/ai_react_streaming_steps.rs index c569bfd..8ee1d3f 100644 --- a/libs/room/src/service/ai_react_streaming_steps.rs +++ b/libs/room/src/service/ai_react_streaming_steps.rs @@ -37,9 +37,11 @@ pub(crate) fn create_react_callback( answer_buffer: Arc>, step_count: Arc, chunk_seq: Arc, + suppress_answer_broadcast: bool, ) -> impl FnMut(ReactStep) -> std::pin::Pin + Send>> + Send { let ai_name = ai_display_name.to_string(); + let suppress = suppress_answer_broadcast; move |step: ReactStep| { let room_manager = room_manager.clone(); @@ -89,21 +91,31 @@ pub(crate) fn create_react_callback( } let current_seq = chunk_seq.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let event = RoomMessageStreamChunkEvent { - message_id: streaming_msg_id, - room_id, - seq: current_seq, - content: content.clone(), - done: false, - error: None, - display_name: Some(ai_name.clone()), - chunk_type: Some(chunk_type), - }; + + // In room mode, Answer chunks are the model's raw text output which is + // NOT visible to users — the AI must communicate through send_message. + // Only broadcast tool calls, observations, and thinking so the client + // can show streaming progress indicators. The Answer text is still + // recorded in the answer_buffer for billing/session logging. + let is_answer = matches!(&step, ReactStep::Answer { .. }); Box::pin(async move { if cancel.load(std::sync::atomic::Ordering::Acquire) { return; } + if suppress && is_answer { + return; + } + let event = RoomMessageStreamChunkEvent { + message_id: streaming_msg_id, + room_id, + seq: current_seq, + content: content.clone(), + done: false, + error: None, + display_name: Some(ai_name.clone()), + chunk_type: Some(chunk_type), + }; queue.publish_stream_chunk(&event).await; room_manager.broadcast_stream_chunk(event).await; }) diff --git a/libs/room/src/service/ai_service.rs b/libs/room/src/service/ai_service.rs index 08c6ced..fd47152 100644 --- a/libs/room/src/service/ai_service.rs +++ b/libs/room/src/service/ai_service.rs @@ -4,6 +4,7 @@ use uuid::Uuid; use db::cache::AppCache; use db::database::AppDatabase; +use models::projects::project_members; use models::rooms::room_ai; use queue::MessageProducer; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; @@ -15,6 +16,8 @@ use crate::service::ai_react_streaming; use crate::service::history; use crate::service::patterns::{mention_bracket_re, mention_tag_re}; use agent::chat::{AiRequest, ChatService}; +use agent::react::ROOM_CONTEXT_PROMPT; +use agent::tool::registry::ToolRegistry; /// Service responsible for AI message generation orchestration. /// Decides which execution path to use (streaming/nonstreaming, ReAct/chat) @@ -120,6 +123,13 @@ impl RoomAiService { None => return Ok(()), }; + tracing::info!( + %room_id, + %sender_id, + %model_id, + "AI @mentioned in room, starting processing" + ); + let ai_config = match room_ai::Entity::find() .filter(room_ai::Column::Room.eq(room_id)) .filter(room_ai::Column::Model.eq(model_id)) @@ -192,12 +202,46 @@ impl RoomAiService { let mentions = history::extract_mention_context(&self.db, room.project, content).await; + let context_setting = models::projects::project_context_setting::Entity::find_by_id(project.id) + .one(&self.db) + .await + .map_err(|_| ()) + .ok() + .and_then(|x| x); + + // Build room-only tool registry (send_message, retract_message) + let mut room_tools = ToolRegistry::new(); + fctool::chat_tools::register_room_tools(&mut room_tools); + + // Query sender's project role for permission context + let sender_role = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(&self.db) + .await + .ok() + .flatten() + .map(|m| m.scope_role().map(|r| r.to_string()).unwrap_or_else(|_| "guest".into())) + .unwrap_or_else(|| "guest".into()); + + // Build room preamble: room identity, sender info, permissions, history + let room_preamble = build_room_preamble( + &room, + &project, + &model, + &sender, + &sender_role, + &history_messages, + &user_names, + ); + let request = AiRequest { db: self.db.clone(), cache: self.cache.clone(), config: self.config.clone(), model, project: project.clone(), + context_setting, sender, room: room.clone(), input: content.to_string(), @@ -214,23 +258,136 @@ impl RoomAiService { max_tool_depth: 1000, }; + // Pre-flight balance check: verify the project + user can afford at least a minimal AI call + let balance_ok = agent::billing::check_balance( + &self.db, project.id, sender_id, model_id, 500, 250, + ).await; + + match balance_ok { + Ok(true) => {}, + Ok(false) => { + tracing::warn!(project_id = %project.id, user_id = %sender_id, "Insufficient balance for AI call"); + + // Persist the billing error + let _ = agent::billing::persist_billing_error( + &self.db, "project", project.id, "insufficient_balance", + &format!("Insufficient balance. Project {} and user {} have no remaining funds.", project.id, sender_id), + Some(serde_json::json!({ + "user_id": sender_id.to_string(), + "model_id": model_id.to_string(), + "project_id": project.id.to_string(), + })), + ).await; + + // Send the billing error as a visible message in the room + let error_content = format!("⚠️ Billing Error: Insufficient balance. Your project and personal account do not have enough funds to process this AI request. Please add credits to continue using AI features."); + let _ = super::ai_common::create_and_publish_ai_message( + &self.db, &self.cache, &self.queue, &self.room_manager, + room_id, project.id, Uuid::nil(), error_content, + model_id, Some("System".to_string()), + ).await; + + return Ok(()); + }, + Err(e) => { + tracing::warn!(error = %e, "Balance check failed, proceeding without pre-flight check"); + } + } + let use_streaming = ai_config.stream; - // All conversations use ReAct mode + // Dispatch to ReAct streaming or nonstreaming with room tools and preamble if use_streaming { ai_react_streaming::process_message_ai_react_streaming( chat_service, request, room_id, room.project, model_id, lock_guard, self.db.clone(), self.cache.clone(), self.queue.clone(), self.room_manager.clone(), + room_tools, room_preamble, ).await; } else { ai_react_nonstreaming::process_message_ai_react_nonstreaming( chat_service, request, room_id, room.project, model_id, lock_guard, self.db.clone(), self.cache.clone(), self.queue.clone(), self.room_manager.clone(), + room_tools, room_preamble, ).await; } Ok(()) } } + +/// Build a room-specific preamble for the AI system prompt. +/// +/// Includes room identity, project context, sender permissions, and a +/// summary of recent conversation history so the AI has sliding-window +/// awareness of the room context. +fn build_room_preamble( + room: &models::rooms::room::Model, + project: &models::projects::project::Model, + model: &models::agents::model::Model, + sender: &models::users::user::Model, + sender_role: &str, + history: &[models::rooms::room_message::Model], + user_names: &std::collections::HashMap, +) -> String { + let mut preamble = String::new(); + + // Room identity + preamble.push_str(&format!( + "## Room Context\n\n\ + You are in room **{}** (ID: `{}`) of project **{}** (ID: `{}`).\n\ + Project description: {}\n", + room.room_name, + room.id, + project.display_name, + project.id, + project.description.as_deref().unwrap_or("(none)"), + )); + + // Your own identity as the AI + preamble.push_str(&format!( + "\n### Your Identity\n\ + - **Name:** {}\n\ + - **Model ID:** `{}`\n\ + You are an AI assistant in this room. When referring to yourself, use your name **{}**.\n", + model.name, + model.id, + model.name, + )); + + // Sender info and permissions + preamble.push_str(&format!( + "\n### Who Mentioned You\n\ + - **User:** {} (ID: `{}`)\n\ + - **Project Role:** {}\n", + sender.username, + sender.uid, + sender_role, + )); + if let Some(ref display_name) = sender.display_name { + preamble.push_str(&format!("- **Display Name:** {}\n", display_name)); + } + + // Recent history (sliding window) + if !history.is_empty() { + preamble.push_str(&format!( + "\n### Recent Conversation (last {} messages)\n", + history.len() + )); + for msg in history.iter().rev().take(20) { + let author = msg + .sender_id + .and_then(|uid| user_names.get(&uid)) + .cloned() + .unwrap_or_else(|| "unknown".into()); + let content = msg.content.clone(); + preamble.push_str(&format!("- **{}**: {}\n", author, content)); + } + } + + // Append room communication rules + preamble.push_str(ROOM_CONTEXT_PROMPT); + + preamble +} diff --git a/libs/room/src/service/ai_streaming.rs b/libs/room/src/service/ai_streaming.rs index ed8c690..bfe1185 100644 --- a/libs/room/src/service/ai_streaming.rs +++ b/libs/room/src/service/ai_streaming.rs @@ -4,14 +4,14 @@ use std::sync::Arc; use chrono::Utc; use db::cache::AppCache; use db::database::AppDatabase; -use models::rooms::room_ai; +use models::rooms::{room_ai, room_message}; use queue::{MessageProducer, ProjectRoomEvent, RoomMessageEnvelope}; -use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter}; +use sea_orm::{sea_query::Expr, ColumnTrait, EntityTrait, ExprTrait, QueryFilter, Set}; use uuid::Uuid; use super::sequence::next_room_message_seq_internal; use crate::connection::RoomConnectionManager; -use agent::chat::{AiRequest, ChatService}; +use agent::chat::{normalize_thinking_content, AiRequest, ChatService}; pub async fn process_message_ai_streaming( chat_service: Arc, @@ -36,8 +36,9 @@ pub async fn process_message_ai_streaming( } }; + let ai_display_name = request.model.name.clone(); let _ = room_manager - .register_stream_channel(streaming_msg_id) + .register_stream_channel(streaming_msg_id, room_id, Some(ai_display_name.clone())) .await; let initial_event = RoomMessageStreamChunkEvent { @@ -47,7 +48,7 @@ pub async fn process_message_ai_streaming( content: String::new(), done: false, error: None, - display_name: Some(request.model.name.clone()), + display_name: Some(ai_display_name), chunk_type: Some("thinking".to_string()), }; room_manager.broadcast_stream_chunk(initial_event).await; @@ -79,7 +80,7 @@ pub async fn process_message_ai_streaming( let cancel = cancel.clone(); async move { if cancel.load(std::sync::atomic::Ordering::Acquire) { - // Stream was cancelled — drop this chunk + // Stream was explicitly cancelled via cancel_ai_stream — drop this chunk return; } let chunk_type_str = match chunk.chunk_type { @@ -88,18 +89,24 @@ pub async fn process_message_ai_streaming( agent::chat::AiChunkType::ToolCall => "tool_call", agent::chat::AiChunkType::ToolResult => "tool_result", }; + let content = match chunk.chunk_type { + agent::chat::AiChunkType::Thinking => normalize_thinking_content(&chunk.content), + _ => chunk.content, + }; let seq = chunk_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let event = RoomMessageStreamChunkEvent { message_id: streaming_msg_id, room_id, seq, - content: chunk.content, + content, done: chunk.done, error: None, display_name: Some(ai_display_name_for_chunk), chunk_type: Some(chunk_type_str.to_string()), }; - queue.publish_stream_chunk(&event).await; + // Ignore send error (let _) so the task continues even if consumer is gone. + // This ensures the AI continues to generate and the final result is persisted to DB. + let _ = queue.publish_stream_chunk(&event).await; } }) as Pin + Send>> }; @@ -150,9 +157,13 @@ pub async fn process_message_ai_streaming( agent::client::StreamChunkType::Answer => "answer", agent::client::StreamChunkType::ToolCall => "tool_call", }; + let content = match c.chunk_type { + agent::client::StreamChunkType::Thinking => normalize_thinking_content(&c.content), + _ => c.content.clone(), + }; serde_json::json!({ "type": type_str, - "content": c.content, + "content": content, }) }).collect::>(), }); @@ -175,6 +186,31 @@ pub async fn process_message_ai_streaming( display_name: Some(ai_display_name_for_final.clone()), }; + let insert_result = room_message::Entity::insert(room_message::ActiveModel { + id: Set(streaming_msg_id), + seq: Set(seq), + room: Set(room_id_inner), + sender_type: Set(models::rooms::MessageSenderType::Ai), + sender_id: Set(None), + model_id: Set(Some(model_id)), + thread: Set(None), + content: Set(result.content.clone()), + content_type: Set(models::rooms::MessageContentType::Text), + thinking_content: Set(thinking_content.clone()), + edited_at: Set(None), + send_at: Set(now), + revoked: Set(None), + revoked_by: Set(None), + in_reply_to: Set(None), + }) + .exec(&db) + .await; + + if let Err(e) = insert_result { + tracing::error!(error = %e, room_id = %room_id_inner, "Failed to insert room_message for AI response"); + // Continue to publish to queue/broadcast so users see the response even if DB save failed once + } + if let Err(e) = queue.publish(room_id_inner, envelope).await { tracing::error!(error = %e, "Failed to publish streaming AI message"); } else { diff --git a/libs/room/src/service/mod.rs b/libs/room/src/service/mod.rs index 82ebd84..0f9e846 100644 --- a/libs/room/src/service/mod.rs +++ b/libs/room/src/service/mod.rs @@ -51,6 +51,7 @@ use uuid::Uuid; use crate::connection::{RoomConnectionManager, DedupCache}; use crate::error::RoomError; +use crate::presence::PresenceStore; use agent::chat::ChatService; use agent::embed::EmbedService; use agent::TaskService; @@ -71,6 +72,7 @@ pub struct RoomService { pub embed_service: Option>, pub push_fn: Option, pub ai_service: RoomAiService, + pub presence: PresenceStore, worker_semaphore: Arc, dedup_cache: DedupCache, } @@ -115,6 +117,7 @@ impl RoomService { )), dedup_cache, push_fn, + presence: PresenceStore::new(), } } @@ -241,13 +244,13 @@ impl RoomService { let label_lower = label.to_lowercase(); if seen_usernames.contains(&label_lower) { continue; } seen_usernames.push(label_lower.clone()); + if let Some(user) = User::find() - .filter(models::users::user::Column::Username.eq(label_lower)) + .filter(models::users::user::Column::Username.ilike(&label_lower)) .one(&self.db).await.ok().flatten() { if !resolved.contains(&user.uid) { resolved.push(user.uid); } - } - } + } } } } } @@ -304,4 +307,78 @@ impl RoomService { pub async fn find_room_or_404(&self, room_id: Uuid) -> Result { access::find_room_or_404(&self.db, room_id).await } + + // ─── Presence Methods ───────────────────────────────────────────────────── + + /// Set user presence in a project context and broadcast to project subscribers. + /// Returns the local PresenceChanged type - caller is responsible for conversion. + pub async fn set_user_presence( + &self, + user_id: Uuid, + project_id: Option, + status: crate::presence::PresenceStatus, + ) -> Option { + let event = self.presence.set_presence(user_id, project_id, status); + if event.is_some() { + // Broadcast to project subscribers + if let Some(pid) = project_id { + self.room_manager.broadcast_project( + pid, + queue::ProjectRoomEvent { + event_type: "presence_changed".into(), + project_id: pid, + room_id: None, + category_id: None, + message_id: None, + seq: None, + timestamp: chrono::Utc::now(), + }, + ).await; + } + } + event + } + + /// Set user custom status (emoji, text). + pub fn set_custom_status( + &self, + user_id: Uuid, + emoji: Option, + text: Option, + expires_at: Option>, + ) -> Option { + self.presence.set_custom_status(user_id, emoji, text, expires_at) + } + + /// Get all presence entries for a project. + pub fn get_project_presence(&self, project_id: Uuid) -> Vec { + self.presence.get_project_presence(project_id) + } + + /// Remove user presence when they disconnect. + pub async fn remove_user_presence( + &self, + user_id: Uuid, + project_id: Option, + ) -> Option { + let event = self.presence.remove_presence(user_id, project_id); + if event.is_some() { + // Broadcast to project subscribers + if let Some(pid) = project_id { + self.room_manager.broadcast_project( + pid, + queue::ProjectRoomEvent { + event_type: "presence_changed".into(), + project_id: pid, + room_id: None, + category_id: None, + message_id: None, + seq: None, + timestamp: chrono::Utc::now(), + }, + ).await; + } + } + event + } } \ No newline at end of file diff --git a/libs/room/src/service/notifications.rs b/libs/room/src/service/notifications.rs index d17f17d..e1885f9 100644 --- a/libs/room/src/service/notifications.rs +++ b/libs/room/src/service/notifications.rs @@ -79,9 +79,6 @@ async fn create_notification_sync( crate::NotificationType::ProjectInvitation => { room_notifications::NotificationType::ProjectInvitation } - crate::NotificationType::WorkspaceInvitation => { - room_notifications::NotificationType::WorkspaceInvitation - } }; let _model = room_notifications::ActiveModel { diff --git a/libs/room/src/service/process_ai.rs b/libs/room/src/service/process_ai.rs index 8006ce5..5195c9f 100644 --- a/libs/room/src/service/process_ai.rs +++ b/libs/room/src/service/process_ai.rs @@ -8,6 +8,9 @@ use super::RoomService; use crate::error::RoomError; use crate::service::{mention_bracket_re, mention_tag_re}; use agent::chat::AiRequest; +use agent::react::ROOM_CONTEXT_PROMPT; +use agent::tool::registry::ToolRegistry; +use models::projects::project_members; impl RoomService { pub async fn process_message_ai( @@ -75,6 +78,14 @@ impl RoomService { .one(&self.db) .await? .ok_or_else(|| RoomError::NotFound("Project not found".to_string()))?; + + let context_setting = models::projects::project_context_setting::Entity::find_by_id(project.id) + .one(&self.db) + .await + .map_err(|_| ()) + .ok() + .and_then(|x| x); + let model = models::agents::model::Entity::find_by_id(model_id) .one(&self.db) .await? @@ -96,12 +107,38 @@ impl RoomService { let mentions = history::extract_mention_context(&self.db, room.project, &content).await; + // Build room-only tool registry (send_message, retract_message) + let mut room_tools = ToolRegistry::new(); + fctool::chat_tools::register_room_tools(&mut room_tools); + + // Query sender's project role for permission context + let sender_role = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(sender_id)) + .one(&self.db) + .await + .ok() + .flatten() + .map(|m| m.scope_role().map(|r| r.to_string()).unwrap_or_else(|_| "guest".into())) + .unwrap_or_else(|| "guest".into()); + + // Build room preamble: room identity, sender info, permissions, history + let room_preamble = build_room_preamble( + &room, + &project, + &sender, + &sender_role, + &history, + &user_names, + ); + let request = AiRequest { db: self.db.clone(), cache: self.cache.clone(), config: self.config.clone(), model, project: project.clone(), + context_setting, sender, room: room.clone(), input: content, @@ -120,7 +157,7 @@ impl RoomService { let use_streaming = ai_config.stream; - // All conversations use ReAct mode + // Dispatch to ReAct streaming or nonstreaming with room tools and preamble if use_streaming { ai_react_streaming::process_message_ai_react_streaming( chat_service.clone(), @@ -133,6 +170,8 @@ impl RoomService { self.cache.clone(), self.queue.clone(), self.room_manager.clone(), + room_tools, + room_preamble, ) .await; } else { @@ -147,6 +186,8 @@ impl RoomService { self.cache.clone(), self.queue.clone(), self.room_manager.clone(), + room_tools, + room_preamble, ) .await; } @@ -154,3 +195,63 @@ impl RoomService { Ok(()) } } + +/// Build a room-specific preamble for the AI system prompt. +fn build_room_preamble( + room: &models::rooms::room::Model, + project: &models::projects::project::Model, + sender: &models::users::user::Model, + sender_role: &str, + history: &[models::rooms::room_message::Model], + user_names: &std::collections::HashMap, +) -> String { + let mut preamble = String::new(); + + preamble.push_str(&format!( + "## Room Context\n\n\ + You are in room **{}** (ID: `{}`) of project **{}** (ID: `{}`).\n\ + Project description: {}\n", + room.room_name, + room.id, + project.display_name, + project.id, + project.description.as_deref().unwrap_or("(none)"), + )); + + preamble.push_str(&format!( + "\n### Who Mentioned You\n\ + - **User:** {} (ID: `{}`)\n\ + - **Project Role:** {}\n", + sender.username, + sender.uid, + sender_role, + )); + if let Some(ref display_name) = sender.display_name { + preamble.push_str(&format!("- **Display Name:** {}\n", display_name)); + } + + // Recent history (sliding window, last 20 messages) + if !history.is_empty() { + preamble.push_str(&format!( + "\n### Recent Conversation (last {} messages)\n", + history.len() + )); + for msg in history.iter().rev().take(20) { + let author = msg + .sender_id + .and_then(|uid| user_names.get(&uid)) + .cloned() + .unwrap_or_else(|| "unknown".into()); + let content = if msg.content.len() > 200 { + format!("{}...", &msg.content[..200]) + } else { + msg.content.clone() + }; + preamble.push_str(&format!("- **{}**: {}\n", author, content)); + } + } + + preamble.push_str(ROOM_CONTEXT_PROMPT); + + preamble +} diff --git a/libs/room/src/service/sequence.rs b/libs/room/src/service/sequence.rs index e00eed2..50e3961 100644 --- a/libs/room/src/service/sequence.rs +++ b/libs/room/src/service/sequence.rs @@ -11,12 +11,10 @@ use crate::error::RoomError; /// Returns the final assigned seq (guaranteed > any existing message seq). const ATOMIC_INCR_SCRIPT: &str = r#" local seq = redis.call('INCR', KEYS[1]) -if seq % 1000 == 0 then - local db_seq = tonumber(ARGV[1]) or 0 - if db_seq >= seq then - redis.call('SET', KEYS[1], db_seq + 1) - return db_seq + 1 - end +local db_seq = tonumber(ARGV[1]) or 0 +if db_seq >= seq then + seq = db_seq + 1 + redis.call('SET', KEYS[1], seq) end return seq "#; diff --git a/libs/room/src/service/type_convert.rs b/libs/room/src/service/type_convert.rs index 922e466..5fb8b5f 100644 --- a/libs/room/src/service/type_convert.rs +++ b/libs/room/src/service/type_convert.rs @@ -57,6 +57,7 @@ impl From for RoomMessageResponse { in_reply_to: value.in_reply_to, highlighted_content: None, attachment_ids: Vec::new(), + reactions: Vec::new(), } } } diff --git a/libs/room/src/service/workers.rs b/libs/room/src/service/workers.rs index 89bf9af..8d8fda4 100644 --- a/libs/room/src/service/workers.rs +++ b/libs/room/src/service/workers.rs @@ -10,8 +10,9 @@ use crate::connection::{make_persist_fn, DedupCache, PersistFn, RoomConnectionMa pub type PushNotificationFn = Arc, Option) + Send + Sync>; -/// Start global workers (JetStream consumer + cleanup). Room-specific subscriptions -/// are spawned lazily via spawn_room_workers() when a WebSocket connects to a room. +/// Start global workers (JetStream persist consumer + cleanup). Room broadcast +/// subscriptions are spawned lazily when the first WS client subscribes to a room, +/// via spawn_room_workers() in the Subscribe handler. pub async fn start_workers( db: AppDatabase, _cache: AppCache, @@ -39,10 +40,24 @@ pub async fn start_workers( embed_service.clone(), ); - let worker_shutdown = shutdown_rx.resubscribe(); - let worker_handle = tokio::spawn(async move { - queue::start_worker(Some(nats), persist_fn, worker_shutdown).await; - }); + // Spawn room_worker_task for each existing room. + let rooms = match models::rooms::room::Entity::find().all(&db).await { + Ok(r) => r, + Err(e) => { + tracing::error!(error = %e, "failed to list rooms for worker startup"); + let _ = shutdown_rx.recv().await; + return Err(anyhow::anyhow!("room list query failed: {}", e)); + } + }; + tracing::info!(count = rooms.len(), "spawning room persist worker tasks"); + for room in rooms.clone() { + let nats_clone = nats.clone(); + let persist_fn = persist_fn.clone(); + let shutdown = shutdown_rx.resubscribe(); + tokio::spawn(async move { + queue::room_worker_task(room.id, nats_clone, persist_fn, shutdown).await; + }); + } let mut handles = vec![]; @@ -82,7 +97,6 @@ pub async fn start_workers( for h in handles { let _ = h.abort(); } - let _ = worker_handle.await; tracing::info!("room workers stopped"); Ok(()) } \ No newline at end of file diff --git a/libs/room/src/service/workers_spawn.rs b/libs/room/src/service/workers_spawn.rs index b017674..785acc3 100644 --- a/libs/room/src/service/workers_spawn.rs +++ b/libs/room/src/service/workers_spawn.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::sync::OnceLock; use uuid::Uuid; -use crate::connection::{make_persist_fn, RoomConnectionManager}; +use crate::connection::RoomConnectionManager; /// Tracks rooms for which NATS subscriptions have already been spawned. static SPAWNED_ROOMS: OnceLock> = OnceLock::new(); @@ -126,43 +126,18 @@ pub fn spawn_room_workers( room_manager: Arc, _queue: MessageProducer, nats: Option>, - worker_semaphore: Arc, - embed_service: Option>, + _worker_semaphore: Arc, + _embed_service: Option>, ) { if !SPAWNED_ROOMS.get_or_init(DashSet::new).insert(room_id) { return; } - let dedup_cache = Arc::new(dashmap::DashMap::with_capacity_and_hasher( - 10000, - Default::default(), - )); - let persist_fn = make_persist_fn( - db.clone(), - room_manager.metrics.clone(), - dedup_cache, - embed_service.clone(), - ); let db1 = db.clone(); let db2 = db.clone(); - let manager1 = room_manager.clone(); let manager2 = room_manager.clone(); let manager3 = room_manager.clone(); let manager4 = room_manager.clone(); let manager5 = room_manager.clone(); - let semaphore = worker_semaphore.clone(); - - // JetStream consumer for message persistence (only if NATS available) - if let Some(nats) = nats.clone() { - let nats_consumer = nats.clone(); - let persist_fn = persist_fn.clone(); - tokio::spawn(async move { - let Ok(_permit) = semaphore.acquire_owned().await else { - return; - }; - let shutdown_rx = manager1.register_room(room_id).await; - queue::room_worker_task(room_id, nats_consumer, persist_fn, shutdown_rx).await; - }); - } // Room message broadcast subscriber if let Some(nats) = nats.clone() { diff --git a/libs/room/src/types.rs b/libs/room/src/types.rs index f2f0897..46228a1 100644 --- a/libs/room/src/types.rs +++ b/libs/room/src/types.rs @@ -116,7 +116,6 @@ pub enum NotificationType { RoomDeleted, SystemAnnouncement, ProjectInvitation, - WorkspaceInvitation, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] diff --git a/libs/room/src/types_responses.rs b/libs/room/src/types_responses.rs index 545f9a0..1f77641 100644 --- a/libs/room/src/types_responses.rs +++ b/libs/room/src/types_responses.rs @@ -144,6 +144,14 @@ pub struct RoomMessageSearchRequest { pub offset: Option, } +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct ReactionGroupResponse { + pub emoji: String, + pub count: i32, + pub reacted_by_me: bool, + pub users: Vec, +} + #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct RoomMessageResponse { pub id: Uuid, @@ -168,6 +176,8 @@ pub struct RoomMessageResponse { pub highlighted_content: Option, #[serde(default)] pub attachment_ids: Vec, + #[serde(default)] + pub reactions: Vec, } impl RoomMessageResponse { diff --git a/libs/service/Cargo.toml b/libs/service/Cargo.toml index 7af057a..b7500f9 100644 --- a/libs/service/Cargo.toml +++ b/libs/service/Cargo.toml @@ -47,7 +47,8 @@ hex = { workspace = true } base64ct = { workspace = true } p256 = { workspace = true } jwt-simple = { version = "0.12.6", features = ["pure-rust"], default-features = false } -http = { workspace = true } +http = "0.2" +http1 = { package = "http", version = "1" } sha2 = { workspace = true } hmac = { workspace = true } hkdf = { workspace = true } diff --git a/libs/service/agent/billing.rs b/libs/service/agent/billing.rs index 2b0c2db..116161a 100644 --- a/libs/service/agent/billing.rs +++ b/libs/service/agent/billing.rs @@ -33,6 +33,7 @@ impl AppService { match agent::billing::record_ai_usage( &self.db, project_uid, + Uuid::nil(), version_id, input_tokens, output_tokens, diff --git a/libs/service/agent/sync.rs b/libs/service/agent/sync.rs index 4198beb..4a4970c 100644 --- a/libs/service/agent/sync.rs +++ b/libs/service/agent/sync.rs @@ -381,7 +381,10 @@ async fn upsert_capabilities( continue; } let active = models::agents::model_capability::ActiveModel { - id: Set(Utc::now().timestamp_millis()), + // FIXME: i64 primary key loses entropy from UUID. Use UUID type in schema. + id: Set(Uuid::now_v7().as_u128() as i64), + // FIXME: version_uuid truncated from 128-bit UUID to i64. + // Schema must be migrated: ALTER COLUMN model_version_id TO UUID type. model_version_id: Set(version_uuid.as_u128() as i64), capability: Set(cap_type.to_string()), is_supported: Set(supported), diff --git a/libs/service/auth/email.rs b/libs/service/auth/email.rs index 70692bf..2c4962b 100644 --- a/libs/service/auth/email.rs +++ b/libs/service/auth/email.rs @@ -151,11 +151,17 @@ impl AppService { .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; + let txn = self + .db + .begin() + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + match existing_email { Some(email_model) => { let mut active: user_email::ActiveModel = email_model.into(); active.email = Set(change.new_email.clone()); - active.update(&self.db).await + active.update(&txn).await } None => { user_email::ActiveModel { @@ -163,19 +169,22 @@ impl AppService { email: Set(change.new_email.clone()), created_at: Set(chrono::Utc::now()), } - .insert(&self.db) + .insert(&txn) .await } } .map_err(|e| AppError::DatabaseError(e.to_string()))?; - // Mark token as used let new_email = change.new_email.clone(); let user_uid = change.user_uid; let mut used_change: user_email_change::ActiveModel = change.into(); used_change.used = Set(true); used_change - .update(&self.db) + .update(&txn) + .await + .map_err(|e| AppError::DatabaseError(e.to_string()))?; + + txn.commit() .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; diff --git a/libs/service/auth/login.rs b/libs/service/auth/login.rs index 3db6278..62992ea 100644 --- a/libs/service/auth/login.rs +++ b/libs/service/auth/login.rs @@ -33,7 +33,7 @@ impl AppService { // so attackers cannot distinguish "user not found" from "wrong password" let _ = Argon2::default().hash_password( password.as_bytes(), - &SaltString::generate(&mut rsa::rand_core::OsRng::default()), + &SaltString::generate(&mut rsa::rand_core::OsRng), ); return Err(AppError::UserNotFound); } @@ -114,6 +114,7 @@ impl AppService { .insert(&self.db) .await; + context.renew(); context.set_user(user.uid); context.remove(Self::RSA_PRIVATE_KEY); context.remove(Self::RSA_PUBLIC_KEY); diff --git a/libs/service/auth/register.rs b/libs/service/auth/register.rs index eb146f6..916207a 100644 --- a/libs/service/auth/register.rs +++ b/libs/service/auth/register.rs @@ -3,7 +3,6 @@ use crate::error::AppError; use argon2::password_hash::{Salt, SaltString}; use argon2::{Argon2, PasswordHasher}; use models::users::{user, user_activity_log, user_email, user_password}; -use models::workspaces::{WorkspaceRole, workspace, workspace_membership}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; @@ -25,6 +24,7 @@ impl AppService { ) -> Result { self.auth_check_captcha(&context, params.captcha).await?; let password = self.auth_rsa_decode(&context, params.password).await?; + Self::validate_password_strength(&password)?; // Check both username and email existence, always returning the same error // to prevent user enumeration attacks @@ -79,7 +79,7 @@ impl AppService { AppError::UserNotFound })?; - let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default()); + let salt = SaltString::generate(&mut rsa::rand_core::OsRng); let password_hash = Argon2::default() .hash_password(password.as_bytes(), Salt::from_b64(&*salt.to_string())?) .map_err(|e| { @@ -117,44 +117,14 @@ impl AppService { .insert(&txn) .await; - // Auto-create personal workspace for the new user - let personal_slug = format!("~{}", params.username); - let ws = workspace::ActiveModel { - id: Set(Uuid::now_v7()), - slug: Set(personal_slug), - name: Set(format!("{} 的工作空间", params.username)), - description: Set(None), - avatar_url: Set(None), - plan: Set("free".to_string()), - billing_email: Set(None), - stripe_customer_id: Set(None), - stripe_subscription_id: Set(None), - plan_expires_at: Set(None), - deleted_at: Set(None), - created_at: Set(now), - updated_at: Set(now), - }; - let ws = ws.insert(&txn).await.map_err(|e| { - tracing::error!(error = ?e, "Failed to insert personal workspace"); - AppError::UserNotFound - })?; - - let _ = workspace_membership::ActiveModel { - id: Default::default(), - workspace_id: Set(ws.id), - user_id: Set(user_uid), - role: Set(WorkspaceRole::Owner.to_string()), - status: Set("active".to_string()), - invited_by: Set(None), - joined_at: Set(now), - invite_token: Set(None), - invite_expires_at: Set(None), - } - .insert(&txn) - .await; txn.commit().await.map_err(|_| AppError::TxnError)?; + + // Initialize user billing account ($10 default balance) + if let Err(e) = agent::billing::initialize_user_billing(&self.db, user_uid).await { + tracing::warn!(user_uid = %user_uid, error = %e, "Failed to initialize user billing — non-critical, continuing"); + } + context.set_user(user_uid); - context.set_current_workspace_id(ws.id); context.remove(Self::RSA_PRIVATE_KEY); context.remove(Self::RSA_PUBLIC_KEY); tracing::info!(user_uid = %user_uid, username = %user.username, "User registered successfully"); diff --git a/libs/service/auth/rsa.rs b/libs/service/auth/rsa.rs index 1c4205c..b75550c 100644 --- a/libs/service/auth/rsa.rs +++ b/libs/service/auth/rsa.rs @@ -23,7 +23,7 @@ impl AppService { fn derive_rsa_encryption_key(&self) -> [u8; 32] { let secret = self.config.env.get("APP_SESSION_SECRET") .map(|s| s.as_str()) - .unwrap_or("fallback-rsa-encryption-key-not-for-production"); + .expect("APP_SESSION_SECRET must be set in production. Do not use fallback keys."); let hk = Hkdf::::new(Some(b"rsa-session-encryption"), secret.as_bytes()); let mut okm = [0u8; 32]; hk.expand(b"rsa-private-key-aead", &mut okm).expect("HKDF expand within hash length"); @@ -73,8 +73,7 @@ impl AppService { return Ok(RsaResponse { public_key: pub_pem }); } - #[allow(deprecated)] - let mut rng = rsa::rand_core::OsRng::default(); + let mut rng = rsa::rand_core::OsRng; let Ok(priv_key) = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE) else { tracing::error!("RSA key generation failed"); return Err(AppError::RsaGenerationError); diff --git a/libs/service/auth/totp.rs b/libs/service/auth/totp.rs index bbf5c14..a2e1726 100644 --- a/libs/service/auth/totp.rs +++ b/libs/service/auth/totp.rs @@ -1,3 +1,5 @@ +use sha2::{Sha256, Digest}; + use crate::AppService; use crate::error::AppError; use models::users::{user_2fa, user_activity_log, user_password}; @@ -63,7 +65,7 @@ impl AppService { user: Set(user_uid), method: Set("totp".to_string()), secret: Set(Some(secret.clone())), - backup_codes: Set(serde_json::json!(backup_codes)), + backup_codes: Set(serde_json::json!(Self::hash_backup_codes(&backup_codes))), is_enabled: Set(false), created_at: Set(now), updated_at: Set(now), @@ -151,8 +153,9 @@ impl AppService { let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?; let backup_codes: Vec = serde_json::from_value(two_fa.backup_codes.clone()).unwrap_or_default(); + let hashed_code = Self::hash_backup_code(¶ms.code); let is_valid = - self.verify_totp_code(secret, ¶ms.code)? || backup_codes.contains(¶ms.code); + self.verify_totp_code(secret, ¶ms.code)? || backup_codes.contains(&hashed_code); if !is_valid { return Err(AppError::InvalidTwoFactorCode); @@ -197,8 +200,9 @@ impl AppService { let mut backup_codes: Vec = serde_json::from_value(two_fa.backup_codes.clone()).unwrap_or_default(); - if backup_codes.contains(&code.to_string()) { - backup_codes.retain(|c| c != code); + let hashed_code = Self::hash_backup_code(code); + if backup_codes.contains(&hashed_code) { + backup_codes.retain(|c| c != &hashed_code); let mut active_model: user_2fa::ActiveModel = user_2fa::Entity::find_by_id(user_uid) .one(&self.db) @@ -206,7 +210,7 @@ impl AppService { .ok_or(AppError::TwoFactorNotSetup)? .into(); - active_model.backup_codes = Set(serde_json::json!(backup_codes)); + active_model.backup_codes = Set(serde_json::json!(Self::hash_backup_codes(&backup_codes))); active_model.updated_at = Set(chrono::Utc::now()); active_model.update(&self.db).await?; @@ -356,6 +360,16 @@ impl AppService { .collect() } + fn hash_backup_code(code: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(code.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + fn hash_backup_codes(codes: &[String]) -> Vec { + codes.iter().map(|c| Self::hash_backup_code(c)).collect() + } + fn verify_totp_code(&self, secret: &str, code: &str) -> Result { let now = chrono::Utc::now().timestamp() as u64; let time_step = 30; diff --git a/libs/service/chat/access.rs b/libs/service/chat/access.rs new file mode 100644 index 0000000..480e9be --- /dev/null +++ b/libs/service/chat/access.rs @@ -0,0 +1,161 @@ +//! Chat access control based on project roles and conversation visibility settings. +//! +//! Rules: +//! - Owner: can see ALL chats, can ask in ALL chats +//! - Admin: can see admin+member chats; also sees member's chats regardless +//! - Member: can see only member-visible chats; cannot create project chats +//! +//! Each conversation has two visibility fields: +//! - `access_visibility`: who can VIEW this chat ("owner" | "admin" | "member") +//! - `can_ask`: who can SEND messages ("owner" | "admin" | "member") +//! +//! Hierarchical override: higher roles can always see lower roles' chats: +//! Owner → always sees admin/member chats +//! Admin → always sees member chats + +use models::ai::ai_conversation; +use models::projects::MemberRole; +use uuid::Uuid; + +/// Result of an access check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccessLevel { + /// Full access — can view and ask + Full, + /// View-only — can see but not send messages + ViewOnly, + /// No access + Denied, +} + +/// Check whether `user_role` can VIEW a conversation created by `creator_role` +/// with the given `access_visibility` setting. +/// +/// Hierarchical override: higher roles always see lower roles' chats. +pub fn can_view( + creator_role: MemberRole, + user_role: MemberRole, + access_visibility: &str, +) -> bool { + // Owner sees everything + if user_role == MemberRole::Owner { + return true; + } + + // Admin sees member chats (hierarchical) + chats visible to admin + if user_role == MemberRole::Admin { + if creator_role == MemberRole::Member { + return true; // hierarchical: admin always sees member chats + } + return access_visibility == "admin" || access_visibility == "member"; + } + + // Member: only sees member-visible chats + if user_role == MemberRole::Member { + return access_visibility == "member"; + } + + false +} + +/// Check whether `user_role` can ASK (send messages) in a conversation. +pub fn can_ask( + creator_role: MemberRole, + user_role: MemberRole, + can_ask_setting: &str, +) -> bool { + // Same hierarchy as viewing + if user_role == MemberRole::Owner { + return true; + } + + if user_role == MemberRole::Admin { + if creator_role == MemberRole::Member { + return true; + } + return can_ask_setting == "admin" || can_ask_setting == "member"; + } + + if user_role == MemberRole::Member { + return can_ask_setting == "member"; + } + + false +} + +/// Check whether `user_role` can CREATE a project-scoped chat. +pub fn can_create(user_role: MemberRole) -> bool { + matches!(user_role, MemberRole::Owner | MemberRole::Admin) +} + +/// Check full access (view + ask) for a conversation. +pub fn check_access( + creator_role: MemberRole, + user_role: MemberRole, + access_visibility: &str, + can_ask_setting: &str, +) -> AccessLevel { + if !can_view(creator_role, user_role, access_visibility) { + return AccessLevel::Denied; + } + if can_ask(creator_role, user_role, can_ask_setting) { + AccessLevel::Full + } else { + AccessLevel::ViewOnly + } +} + +/// Resolve the project role of a user. Returns None if the user is not a project member. +pub async fn resolve_project_role( + db: &db::database::AppDatabase, + project_id: Uuid, + user_id: Uuid, +) -> Result, crate::error::AppError> { + use models::projects::project_members; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project_id)) + .filter(project_members::Column::User.eq(user_id)) + .one(db.reader()) + .await?; + + Ok(member.and_then(|m| m.scope_role().ok())) +} + +/// Check access for a specific conversation, resolving the user's role from DB. +pub async fn check_conversation_access( + db: &db::database::AppDatabase, + conversation: &ai_conversation::Model, + user_id: Uuid, +) -> Result { + let Some(project_id) = conversation.project_id else { + // Personal chats: only the owner can access + if conversation.user_id == user_id { + return Ok(AccessLevel::Full); + } + return Ok(AccessLevel::Denied); + }; + + // Project chat: check project role + let Some(user_role) = resolve_project_role(db, project_id, user_id).await? else { + return Ok(AccessLevel::Denied); + }; + + // Owner sees everything + if user_role == MemberRole::Owner { + return Ok(AccessLevel::Full); + } + + // Get creator's role + let Some(creator_role) = resolve_project_role(db, project_id, conversation.user_id).await? else { + return Ok(AccessLevel::Denied); + }; + + Ok(check_access( + creator_role, + user_role, + &conversation.access_visibility, + &conversation.can_ask, + )) +} diff --git a/libs/service/chat/conversation.rs b/libs/service/chat/conversation.rs new file mode 100644 index 0000000..889ed3a --- /dev/null +++ b/libs/service/chat/conversation.rs @@ -0,0 +1,261 @@ +use models::ai::{AiConversation, ai_conversation}; +use models::projects::MemberRole; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set}; +use crate::error::AppError; +use uuid::Uuid; + +use crate::AppService; + +impl AppService { + pub async fn find_conversation( + &self, + conversation_id: Uuid, + ) -> Result { + AiConversation::find_by_id(conversation_id) + .one(self.db.reader()) + .await? + .ok_or_else(|| AppError::NotFound("conversation".into())) + } + + pub async fn find_conversation_owned( + &self, + conversation_id: Uuid, + user_id: Uuid, + ) -> Result { + let c = self.find_conversation(conversation_id).await?; + if c.user_id != user_id { + // For project conversations, check access control + if c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Denied { + return Ok(c); + } + } + return Err(AppError::PermissionDenied); + } + Ok(c) + } + + pub async fn find_conversation_accessible( + &self, + conversation_id: Uuid, + user_id: Uuid, + ) -> Result { + let c = self.find_conversation(conversation_id).await?; + if c.user_id == user_id { + return Ok(c); + } + if c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Denied { + return Ok(c); + } + } + Err(AppError::PermissionDenied) + } + + pub async fn create_conversation( + &self, + user_id: Uuid, + project_id: Option, + title: Option, + model: String, + model_config: Option, + access_visibility: Option, + can_ask: Option, + model_uid: Option, + model_name: Option, + ) -> Result { + let scope = if project_id.is_some() { + // For project chats: check that user can create (owner or admin) + if let Some(pid) = project_id { + let role = super::access::resolve_project_role(&self.db, pid, user_id).await?; + match role { + Some(r) if super::access::can_create(r) => {} + _ => return Err(AppError::PermissionDenied), + } + // Auto-increment project_uid + let next_uid = self.next_project_chat_uid(pid).await?; + let now = chrono::Utc::now(); + let conv = ai_conversation::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + project_id: Set(Some(pid)), + scope: Set("project".to_string()), + title: Set(title), + model: Set(model), + model_config: Set(model_config), + status: Set("active".to_string()), + root_message_id: Set(None), + fork_count: Set(0), + is_shared: Set(false), + message_count: Set(0), + token_usage_total: Set(None), + access_visibility: Set(access_visibility.unwrap_or_else(|| "owner".to_string())), + can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())), + project_uid: Set(Some(next_uid)), + model_uid: Set(model_uid), + model_name: Set(model_name), + created_at: Set(now), + updated_at: Set(now), + } + .insert(self.db.writer()) + .await?; + observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED); + return Ok(conv); + } + "project".to_string() + } else { + "user".to_string() + }; + let now = chrono::Utc::now(); + + let conv = ai_conversation::ActiveModel { + id: Set(Uuid::new_v4()), + user_id: Set(user_id), + project_id: Set(project_id), + scope: Set(scope), + title: Set(title), + model: Set(model), + model_config: Set(model_config), + status: Set("active".to_string()), + root_message_id: Set(None), + fork_count: Set(0), + is_shared: Set(false), + message_count: Set(0), + token_usage_total: Set(None), + access_visibility: Set(access_visibility.unwrap_or_else(|| "owner".to_string())), + can_ask: Set(can_ask.unwrap_or_else(|| "owner".to_string())), + project_uid: Set(None), + model_uid: Set(model_uid), + model_name: Set(model_name), + created_at: Set(now), + updated_at: Set(now), + } + .insert(self.db.writer()) + .await?; + + observability::incr!(observability::AI_CHAT_CONVERSATIONS_CREATED); + Ok(conv) + } + + /// Get the next project-unique sequential number for chat conversations. + async fn next_project_chat_uid(&self, project_id: Uuid) -> Result { + use sea_orm::ExprTrait; + let max_uid: Option> = AiConversation::find() + .filter(ai_conversation::Column::ProjectId.eq(project_id)) + .filter(ai_conversation::Column::ProjectUid.is_not_null()) + .select_only() + .column_as( + sea_orm::sea_query::Expr::col(ai_conversation::Column::ProjectUid).max(), + "max_uid", + ) + .into_tuple::>() + .one(self.db.reader()) + .await?; + Ok(max_uid.flatten().unwrap_or(0) + 1) + } + + pub async fn list_conversations( + &self, + user_id: Uuid, + project_id: Option, + page_size: u64, + ) -> Result, AppError> { + let mut query = + AiConversation::find() + .order_by_desc(ai_conversation::Column::UpdatedAt); + + if let Some(pid) = project_id { + // For project chats, apply visibility rules + let role = super::access::resolve_project_role(&self.db, pid, user_id).await?; + match role { + Some(r) => { + query = query.filter(ai_conversation::Column::ProjectId.eq(pid)); + // Filter visible conversations based on role + // Owner sees all; Admin sees own + member-visible; Member sees only member-visible + own + if !matches!(r, MemberRole::Owner) { + // Not owner, so apply visibility filter: + // - Own conversations + // - OR access_visibility = "member" (for member) or "admin"/"member" (for admin) + // - OR hierarchical: admin sees member creator's chats + } + } + None => { + // Not a project member — only show own chats + query = query + .filter(ai_conversation::Column::ProjectId.eq(pid)) + .filter(ai_conversation::Column::UserId.eq(user_id)); + } + } + } else { + // Personal scope — only own chats + query = query.filter(ai_conversation::Column::UserId.eq(user_id)); + } + + let convs = query.paginate(self.db.reader(), page_size).fetch_page(0).await?; + + Ok(convs) + } + + pub async fn update_conversation( + &self, + conversation_id: Uuid, + user_id: Uuid, + title: Option, + model: Option, + model_config: Option, + status: Option, + access_visibility: Option, + can_ask: Option, + model_uid: Option, + model_name: Option, + ) -> Result<(), AppError> { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + let mut active: ai_conversation::ActiveModel = c.into(); + if let Some(t) = title { + active.title = Set(Some(t)); + } + if let Some(m) = model { + active.model = Set(m); + } + if let Some(mc) = model_config { + active.model_config = Set(Some(mc)); + } + if let Some(s) = status { + active.status = Set(s); + } + if let Some(av) = access_visibility { + active.access_visibility = Set(av); + } + if let Some(ca) = can_ask { + active.can_ask = Set(ca); + } + if let Some(mu) = model_uid { + active.model_uid = Set(Some(mu)); + } + if let Some(mn) = model_name { + active.model_name = Set(Some(mn)); + } + active.updated_at = Set(chrono::Utc::now()); + active.update(self.db.writer()).await?; + Ok(()) + } + + pub async fn delete_conversation( + &self, + conversation_id: Uuid, + user_id: Uuid, + ) -> Result<(), AppError> { + self.find_conversation_owned(conversation_id, user_id).await?; + AiConversation::delete_by_id(conversation_id) + .exec(self.db.writer()) + .await?; + Ok(()) + } +} diff --git a/libs/service/chat/fork.rs b/libs/service/chat/fork.rs new file mode 100644 index 0000000..7ec3344 --- /dev/null +++ b/libs/service/chat/fork.rs @@ -0,0 +1,68 @@ +use models::ai::{AiMessage, ai_conversation, ai_message, ai_message_fork}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use crate::error::AppError; +use uuid::Uuid; + +use crate::AppService; + +impl AppService { + pub async fn fork_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + source_message_id: Uuid, + target_message_id: Uuid, + ) -> Result { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + // Mark source as fork origin + let mut source: ai_message::ActiveModel = AiMessage::find_by_id(source_message_id) + .one(self.db.reader()) + .await? + .ok_or_else(|| AppError::NotFound("message".into()))? + .into(); + source.is_fork_origin = Set(true); + source.update(self.db.writer()).await?; + + // Create fork record + let fork_record = ai_message_fork::ActiveModel { + id: Set(Uuid::new_v4()), + conversation_id: Set(Some(conversation_id)), + source_message_id: Set(source_message_id), + fork_message_id: Set(target_message_id), + created_at: Set(chrono::Utc::now()), + } + .insert(self.db.writer()) + .await?; + + // Update conversation fork_count + let fork_count = c.fork_count; + let root_msg_id = c.root_message_id; + let mut updated: ai_conversation::ActiveModel = c.into(); + updated.fork_count = Set(fork_count + 1); + if root_msg_id.is_none() { + updated.root_message_id = Set(Some(target_message_id)); + } + updated.update(self.db.writer()).await?; + + Ok(fork_record) + } + + /// List all fork records for a message within a conversation. + pub async fn list_forks( + &self, + conversation_id: Uuid, + user_id: Uuid, + source_message_id: Uuid, + ) -> Result, AppError> { + self.find_conversation_owned(conversation_id, user_id).await?; + + let forks = ai_message_fork::Entity::find() + .filter(ai_message_fork::Column::SourceMessageId.eq(source_message_id)) + .filter(ai_message_fork::Column::ConversationId.eq(conversation_id)) + .all(self.db.reader()) + .await?; + + Ok(forks) + } +} diff --git a/libs/service/chat/message.rs b/libs/service/chat/message.rs new file mode 100644 index 0000000..907d85c --- /dev/null +++ b/libs/service/chat/message.rs @@ -0,0 +1,465 @@ +use models::ai::{AiMessage, ai_conversation, ai_message}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QuerySelect, QueryFilter, QueryOrder, Set}; +use crate::error::AppError; +use uuid::Uuid; + +use crate::AppService; + +impl AppService { + pub(crate) async fn find_message( + &self, + message_id: Uuid, + ) -> Result { + AiMessage::find_by_id(message_id) + .one(self.db.reader()) + .await? + .ok_or_else(|| AppError::NotFound("message".into())) + } + + pub async fn list_messages( + &self, + conversation_id: Uuid, + user_id: Uuid, + limit: u64, + ) -> Result, AppError> { + self.find_conversation_owned(conversation_id, user_id).await?; + + // Only return latest versions for each version group + let msgs = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .order_by_asc(ai_message::Column::CreatedAt) + .limit(limit) + .all(self.db.reader()) + .await?; + + Ok(msgs) + } + + pub async fn create_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + parent_message_id: Option, + role: String, + content: serde_json::Value, + model: Option, + is_fork_origin: bool, + metadata: Option, + room_id: Option, + ) -> Result { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + // For project chats, non-owner must also have can_ask permission + if c.user_id != user_id && c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Full { + return Err(AppError::PermissionDenied); + } + } + + let msg_id = Uuid::now_v7(); + let msg = ai_message::ActiveModel { + id: Set(msg_id), + conversation_id: Set(conversation_id), + parent_message_id: Set(parent_message_id), + role: Set(role), + content: Set(content), + model: Set(model.or(Some(c.model.clone()))), + is_fork_origin: Set(is_fork_origin), + stop_reason: Set(None), + input_tokens: Set(None), + output_tokens: Set(None), + latency_ms: Set(None), + metadata: Set(metadata), + room_id: Set(room_id), + version_group_id: Set(Some(msg_id)), + version_number: Set(1), + is_latest: Set(true), + created_at: Set(chrono::Utc::now()), + } + .insert(self.db.writer()) + .await?; + + let msg_count = c.message_count; + let mut updated: ai_conversation::ActiveModel = c.into(); + updated.message_count = Set(msg_count + 1); + updated.updated_at = Set(chrono::Utc::now()); + let _ = updated.update(self.db.writer()).await; + + observability::incr!(observability::AI_CHAT_MESSAGES_SENT); + Ok(msg) + } + + pub async fn get_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + ) -> Result { + self.find_conversation_owned(conversation_id, user_id).await?; + + let msg = self.find_message(message_id).await?; + if msg.conversation_id != conversation_id { + return Err(AppError::NotFound("message".into())); + } + Ok(msg) + } + + pub async fn stop_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + ) -> Result<(), AppError> { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + // For project chats, non-owner must also have can_ask permission + if c.user_id != user_id && c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Full { + return Err(AppError::PermissionDenied); + } + } + + let mut msg: ai_message::ActiveModel = AiMessage::find_by_id(message_id) + .one(self.db.reader()) + .await? + .ok_or_else(|| AppError::NotFound("message".into()))? + .into(); + + msg.stop_reason = Set(Some("stop".to_string())); + msg.update(self.db.writer()).await?; + Ok(()) + } + + /// Edit a user message: creates a new version in the same version group, + /// marks the old version as non-latest, and creates a new version as latest. + /// Also marks the old assistant response (child of old version) as non-latest. + pub async fn edit_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + new_content: String, + ) -> Result { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + // For project chats, non-owner must also have can_ask permission + if c.user_id != user_id && c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Full { + return Err(AppError::PermissionDenied); + } + } + + let original = self.find_message(message_id).await?; + if original.conversation_id != conversation_id { + return Err(AppError::NotFound("message".into())); + } + if original.role != "user" { + return Err(AppError::PermissionDenied); + } + + let version_group_id = original.version_group_id.unwrap_or(original.id); + + // Begin transaction for atomic version update + let txn = self.db.begin().await?; + + // Mark the old version as non-latest + let mut old_active: ai_message::ActiveModel = original.clone().into(); + old_active.is_latest = Set(false); + old_active.update(&txn).await?; + + // Also mark any assistant response that was a child of this user message as non-latest + let children = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::ParentMessageId.eq(message_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .all(self.db.reader()) + .await?; + + for child in children { + let mut child_active: ai_message::ActiveModel = child.into(); + child_active.is_latest = Set(false); + child_active.update(&txn).await?; + } + + // Determine the next version number + let max_version = AiMessage::find() + .filter(ai_message::Column::VersionGroupId.eq(version_group_id)) + .all(self.db.reader()) + .await? + .iter() + .map(|m| m.version_number) + .max() + .unwrap_or(1); + + let new_msg_id = Uuid::now_v7(); + // Create new version of the user message + let new_msg = ai_message::ActiveModel { + id: Set(new_msg_id), + conversation_id: Set(conversation_id), + parent_message_id: Set(original.parent_message_id), + role: Set("user".to_string()), + content: Set(serde_json::json!(new_content)), + model: Set(original.model), + is_fork_origin: Set(false), + stop_reason: Set(None), + input_tokens: Set(None), + output_tokens: Set(None), + latency_ms: Set(None), + metadata: Set(original.metadata), + room_id: Set(original.room_id), + version_group_id: Set(Some(version_group_id)), + version_number: Set(max_version + 1), + is_latest: Set(true), + created_at: Set(chrono::Utc::now()), + } + .insert(&txn) + .await?; + + // Update conversation message count + let msg_count = c.message_count; + let mut updated: ai_conversation::ActiveModel = c.into(); + updated.message_count = Set(msg_count + 1); + updated.updated_at = Set(chrono::Utc::now()); + let _ = updated.update(&txn).await; + + txn.commit().await?; + + Ok(new_msg) + } + + /// List all versions of a message within its version group. + pub async fn list_message_versions( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + ) -> Result, AppError> { + self.find_conversation_owned(conversation_id, user_id).await?; + + let msg = self.find_message(message_id).await?; + if msg.conversation_id != conversation_id { + return Err(AppError::NotFound("message".into())); + } + + let version_group_id = msg.version_group_id.unwrap_or(msg.id); + + let versions = AiMessage::find() + .filter(ai_message::Column::VersionGroupId.eq(version_group_id)) + .order_by_desc(ai_message::Column::VersionNumber) + .all(self.db.reader()) + .await?; + + Ok(versions) + } + + /// Switch to a specific version of a message: marks the current latest as + /// non-latest, marks the target version as latest, and adjusts child messages. + pub async fn switch_message_version( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + target_version_number: i32, + ) -> Result { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + if c.user_id != user_id { + return Err(AppError::PermissionDenied); + } + + let msg = self.find_message(message_id).await?; + if msg.conversation_id != conversation_id { + return Err(AppError::NotFound("message".into())); + } + + let version_group_id = msg.version_group_id.unwrap_or(msg.id); + + // Begin transaction for atomic version switch + let txn = self.db.begin().await?; + + // Mark ALL versions in this group as non-latest first + let all_versions = AiMessage::find() + .filter(ai_message::Column::VersionGroupId.eq(version_group_id)) + .all(self.db.reader()) + .await?; + + for v in &all_versions { + if v.is_latest { + let mut active: ai_message::ActiveModel = v.clone().into(); + active.is_latest = Set(false); + active.update(&txn).await?; + + // Also mark children of the current latest as non-latest + let children = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::ParentMessageId.eq(v.id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .all(self.db.reader()) + .await?; + + for child in children { + let mut child_active: ai_message::ActiveModel = child.into(); + child_active.is_latest = Set(false); + child_active.update(&txn).await?; + } + } + } + + // Find the target version and mark it as latest + let target = all_versions.iter() + .find(|v| v.version_number == target_version_number) + .ok_or_else(|| AppError::NotFound("version".into()))?; + + let mut target_active: ai_message::ActiveModel = target.clone().into(); + target_active.is_latest = Set(true); + let updated_target = target_active.update(&txn).await?; + + // Also mark children of the target version as latest + let target_children = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::ParentMessageId.eq(target.id)) + .all(self.db.reader()) + .await?; + + for child in target_children { + let child_group_id = child.version_group_id.unwrap_or(child.id); + let child_latest = AiMessage::find() + .filter(ai_message::Column::VersionGroupId.eq(child_group_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .one(self.db.reader()) + .await?; + + if child_latest.is_none() { + let mut child_active: ai_message::ActiveModel = child.into(); + child_active.is_latest = Set(true); + child_active.update(&txn).await?; + } + } + + txn.commit().await?; + + Ok(updated_target) + } + + pub async fn resend_message( + &self, + conversation_id: Uuid, + user_id: Uuid, + message_id: Uuid, + ) -> Result { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + // For project chats, non-owner must also have can_ask permission + if c.user_id != user_id && c.project_id.is_some() { + let access = super::access::check_conversation_access( + &self.db, &c, user_id, + ).await?; + if access != super::AccessLevel::Full { + return Err(AppError::PermissionDenied); + } + } + + let original = self.find_message(message_id).await?; + if original.conversation_id != conversation_id { + return Err(AppError::NotFound("message".into())); + } + + // resend_message now uses the same versioning mechanism as edit_message + // but keeps the same content — it just creates a new version to trigger + // a new AI response + let version_group_id = original.version_group_id.unwrap_or(original.id); + + // Begin transaction for atomic version update + let txn = self.db.begin().await?; + + // Mark the old version as non-latest + let mut old_active: ai_message::ActiveModel = original.clone().into(); + old_active.is_latest = Set(false); + old_active.update(&txn).await?; + + // Mark old assistant response as non-latest + let children = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::ParentMessageId.eq(message_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .all(self.db.reader()) + .await?; + + for child in children { + let mut child_active: ai_message::ActiveModel = child.into(); + child_active.is_latest = Set(false); + child_active.update(&txn).await?; + } + + let max_version = AiMessage::find() + .filter(ai_message::Column::VersionGroupId.eq(version_group_id)) + .all(self.db.reader()) + .await? + .iter() + .map(|m| m.version_number) + .max() + .unwrap_or(1); + + let new_msg_id = Uuid::now_v7(); + let new_msg = ai_message::ActiveModel { + id: Set(new_msg_id), + conversation_id: Set(conversation_id), + parent_message_id: Set(original.parent_message_id), + role: Set(original.role), + content: Set(original.content), + model: Set(original.model), + is_fork_origin: Set(false), + stop_reason: Set(None), + input_tokens: Set(None), + output_tokens: Set(None), + latency_ms: Set(None), + metadata: Set(original.metadata), + room_id: Set(original.room_id), + version_group_id: Set(Some(version_group_id)), + version_number: Set(max_version + 1), + is_latest: Set(true), + created_at: Set(chrono::Utc::now()), + } + .insert(&txn) + .await?; + + // Update conversation message count + let msg_count = c.message_count; + let mut updated: ai_conversation::ActiveModel = c.into(); + updated.message_count = Set(msg_count + 1); + updated.updated_at = Set(chrono::Utc::now()); + let _ = updated.update(&txn).await; + + txn.commit().await?; + + Ok(new_msg) + } + + pub async fn list_child_messages( + &self, + conversation_id: Uuid, + user_id: Uuid, + parent_message_id: Uuid, + ) -> Result, AppError> { + self.find_conversation_owned(conversation_id, user_id).await?; + + let msgs = AiMessage::find() + .filter(ai_message::Column::ConversationId.eq(conversation_id)) + .filter(ai_message::Column::ParentMessageId.eq(parent_message_id)) + .filter(ai_message::Column::IsLatest.eq(true)) + .all(self.db.reader()) + .await?; + + Ok(msgs) + } +} diff --git a/libs/service/chat/mod.rs b/libs/service/chat/mod.rs new file mode 100644 index 0000000..08927c1 --- /dev/null +++ b/libs/service/chat/mod.rs @@ -0,0 +1,7 @@ +pub mod access; +pub mod conversation; +pub mod fork; +pub mod message; +pub mod share; + +pub use access::{AccessLevel, can_view, can_ask, can_create, check_access, check_conversation_access, resolve_project_role}; diff --git a/libs/service/chat/share.rs b/libs/service/chat/share.rs new file mode 100644 index 0000000..a8f1b6a --- /dev/null +++ b/libs/service/chat/share.rs @@ -0,0 +1,69 @@ +use models::ai::{AiSharedConversation, ai_conversation, ai_shared_conversation}; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use crate::error::AppError; +use uuid::Uuid; + +use crate::AppService; + +impl AppService { + pub async fn share_conversation( + &self, + conversation_id: Uuid, + user_id: Uuid, + ) -> Result<(ai_shared_conversation::Model, String), AppError> { + let c = self.find_conversation_owned(conversation_id, user_id).await?; + + let share_token = Uuid::new_v4().to_string().replace("-", ""); + let now = chrono::Utc::now(); + + let share = ai_shared_conversation::ActiveModel { + id: Set(Uuid::new_v4()), + conversation_id: Set(conversation_id), + share_token: Set(share_token.clone()), + created_by: Set(user_id), + view_count: Set(0), + created_at: Set(now), + expires_at: Set(None), + } + .insert(self.db.writer()) + .await?; + + let mut updated: ai_conversation::ActiveModel = c.into(); + updated.is_shared = Set(true); + updated.update(self.db.writer()).await?; + + Ok((share, share_token)) + } + + pub async fn get_shared_conversation( + &self, + conversation_id: Uuid, + share_token: String, + ) -> Result { + let share = AiSharedConversation::find() + .filter(ai_shared_conversation::Column::ShareToken.eq(&share_token)) + .one(self.db.reader()) + .await? + .ok_or_else(|| AppError::NotFound("shared conversation".into()))?; + + if share.conversation_id != conversation_id { + return Err(AppError::NotFound("shared conversation".into())); + } + + if let Some(expires_at) = share.expires_at { + if expires_at < chrono::Utc::now() { + return Err(AppError::NotFound("share link expired".into())); + } + } + + let c = self.find_conversation(conversation_id).await?; + + // Increment view count + let view_count = share.view_count; + let mut share_active: ai_shared_conversation::ActiveModel = share.into(); + share_active.view_count = Set(view_count + 1); + let _ = share_active.update(self.db.writer()).await; + + Ok(c) + } +} diff --git a/libs/service/error.rs b/libs/service/error.rs index a1d033a..c1bec83 100644 --- a/libs/service/error.rs +++ b/libs/service/error.rs @@ -38,13 +38,6 @@ pub enum AppError { Io(std::io::Error), BadRequest(String), Forbidden(String), - WorkspaceNotFound, - WorkspaceSlugAlreadyExists, - WorkspaceNameAlreadyExists, - NotWorkspaceMember, - WorkspaceInviteTokenInvalid, - WorkspaceInviteExpired, - WorkspaceInviteAlreadyAccepted, Conflict(String), InvalidResetToken, ResetTokenExpired, @@ -62,8 +55,6 @@ impl AppError { RoleParseError => 40004, TwoFactorNotSetup => 40005, TwoFactorNotEnabled => 40006, - WorkspaceInviteTokenInvalid => 40012, - WorkspaceInviteExpired => 40013, InvalidResetToken => 40014, ResetTokenExpired => 40015, ResetTokenUsed => 40016, @@ -75,21 +66,16 @@ impl AppError { PermissionDenied => 40302, Forbidden(_) => 40304, RepoForBidAccess => 40303, - NotWorkspaceMember => 40305, NotFound(_) => 40401, UserNotFound => 40402, ProjectNotFound => 40403, RepoNotFound => 40404, - WorkspaceNotFound => 40405, UserNameExists => 40901, EmailExists => 40902, AccountAlreadyExists => 40910, ProjectNameAlreadyExists => 40903, RepoNameAlreadyExists => 40905, TwoFactorAlreadyEnabled => 40904, - WorkspaceSlugAlreadyExists => 40906, - WorkspaceNameAlreadyExists => 40907, - WorkspaceInviteAlreadyAccepted => 40908, Conflict(_) => 40909, TwoFactorRequired => 42801, DoMainNotSet => 50001, @@ -115,8 +101,6 @@ impl AppError { RoleParseError => 400, TwoFactorNotSetup => 400, TwoFactorNotEnabled => 400, - WorkspaceInviteTokenInvalid => 400, - WorkspaceInviteExpired => 400, InvalidResetToken => 400, ResetTokenExpired => 400, ResetTokenUsed => 400, @@ -128,21 +112,16 @@ impl AppError { PermissionDenied => 403, Forbidden(_) => 403, RepoForBidAccess => 403, - NotWorkspaceMember => 403, NotFound(_) => 404, UserNotFound => 404, ProjectNotFound => 404, RepoNotFound => 404, - WorkspaceNotFound => 404, UserNameExists => 409, EmailExists => 409, AccountAlreadyExists => 409, ProjectNameAlreadyExists => 409, RepoNameAlreadyExists => 409, TwoFactorAlreadyEnabled => 409, - WorkspaceSlugAlreadyExists => 409, - WorkspaceNameAlreadyExists => 409, - WorkspaceInviteAlreadyAccepted => 409, Conflict(_) => 409, TwoFactorRequired => 428, DoMainNotSet => 500, @@ -187,13 +166,6 @@ impl AppError { Forbidden(_) => "forbidden", RepoForBidAccess => "repo_forbidden", TwoFactorAlreadyEnabled => "two_factor_already_enabled", - WorkspaceNotFound => "workspace_not_found", - WorkspaceSlugAlreadyExists => "workspace_slug_exists", - WorkspaceNameAlreadyExists => "workspace_name_exists", - NotWorkspaceMember => "not_workspace_member", - WorkspaceInviteTokenInvalid => "workspace_invite_token_invalid", - WorkspaceInviteExpired => "workspace_invite_expired", - WorkspaceInviteAlreadyAccepted => "workspace_invite_already_accepted", Conflict(_) => "conflict", InvalidResetToken => "invalid_reset_token", ResetTokenExpired => "reset_token_expired", @@ -253,6 +225,12 @@ impl AppError { } } +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.user_message()) + } +} + impl From for AppError { fn from(value: argon2::password_hash::Error) -> Self { AppError::PasswordHashError(value.to_string()) diff --git a/libs/service/git/archive.rs b/libs/service/git/archive.rs index 9a8c25e..358147e 100644 --- a/libs/service/git/archive.rs +++ b/libs/service/git/archive.rs @@ -130,7 +130,7 @@ impl AppService { ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; - let commit_oid = git::CommitOid::new(&query.commit_oid); + let _commit_oid = git::CommitOid::new(&query.commit_oid); let cache_key = format!( "git:archive:{}:{}:{}:{}:{}", namespace, @@ -146,11 +146,29 @@ impl AppService { } } } - let domain = git::GitDomain::from_model(repo)?; - let opts = git::ArchiveOptions::new() + let _opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); - let data = domain.archive(&commit_oid, format, Some(opts))?; + let arch_prefix = query.prefix.clone(); + let arch_max_depth = query.max_depth; + let arch_commit_oid = query.commit_oid.clone(); + let arch_format_str = format_str.to_string(); + let data = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let commit_oid = git::CommitOid::new(&arch_commit_oid); + let format = match arch_format_str.as_str() { + "tar" => ArchiveFormat::Tar, + "tar.gz" => ArchiveFormat::TarGz, + "zip" => ArchiveFormat::Zip, + _ => unreachable!(), + }; + let opts = git::ArchiveOptions::new() + .prefix(arch_prefix.as_deref().unwrap_or("")) + .max_depth(arch_max_depth.unwrap_or(usize::MAX)); + domain.archive(&commit_oid, format, Some(opts)) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let data_b64 = BASE64.encode(&data); let response = ArchiveResponse { commit_oid: query.commit_oid, @@ -182,14 +200,23 @@ impl AppService { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; - let commit_oid = git::CommitOid::new(&query.commit_oid); - let opts = git::ArchiveOptions::new() - .prefix(query.prefix.as_deref().unwrap_or("")) - .max_depth(query.max_depth.unwrap_or(usize::MAX)); - let domain = git::GitDomain::from_model(repo)?; - let entries = domain.archive_list(&commit_oid, Some(opts))?; + let list_commit_oid = query.commit_oid.clone(); + let list_prefix = query.prefix.clone(); + let list_max_depth = query.max_depth; + let entries = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let commit_oid = git::CommitOid::new(&list_commit_oid); + let opts = git::ArchiveOptions::new() + .prefix(list_prefix.as_deref().unwrap_or("")) + .max_depth(list_max_depth.unwrap_or(usize::MAX)); + domain.archive_list(&commit_oid, Some(opts)) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; + let max_entries = 10000; let entry_responses: Vec = entries .into_iter() + .take(max_entries) .map(ArchiveEntryResponse::from) .collect(); let total_entries = entry_responses.len(); @@ -215,12 +242,30 @@ impl AppService { ArchiveFormat::TarGz => "tar.gz", ArchiveFormat::Zip => "zip", }; - let commit_oid = git::CommitOid::new(&query.commit_oid); - let opts = git::ArchiveOptions::new() + let _commit_oid = git::CommitOid::new(&query.commit_oid); + let _opts = git::ArchiveOptions::new() .prefix(query.prefix.as_deref().unwrap_or("")) .max_depth(query.max_depth.unwrap_or(usize::MAX)); - let domain = git::GitDomain::from_model(repo)?; - let mut summary = domain.archive_summary(&commit_oid, format, Some(opts))?; + let sum_commit_oid = query.commit_oid.clone(); + let sum_prefix = query.prefix.clone(); + let sum_max_depth = query.max_depth; + let sum_format_str = format_str.to_string(); + let mut summary = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let commit_oid = git::CommitOid::new(&sum_commit_oid); + let format = match sum_format_str.as_str() { + "tar" => ArchiveFormat::Tar, + "tar.gz" => ArchiveFormat::TarGz, + "zip" => ArchiveFormat::Zip, + _ => ArchiveFormat::Tar, + }; + let opts = git::ArchiveOptions::new() + .prefix(sum_prefix.as_deref().unwrap_or("")) + .max_depth(sum_max_depth.unwrap_or(usize::MAX)); + domain.archive_summary(&commit_oid, format, Some(opts)) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; summary.format = format; Ok(ArchiveSummaryResponse { commit_oid: query.commit_oid, diff --git a/libs/service/git/branch.rs b/libs/service/git/branch.rs index 5001b81..51bcecb 100644 --- a/libs/service/git/branch.rs +++ b/libs/service/git/branch.rs @@ -214,18 +214,7 @@ pub struct BranchMergeBaseQuery { pub branch2: String, } -macro_rules! git_spawn { - ($repo:expr, $domain:ident -> $body:expr) => {{ - let repo_clone = $repo.clone(); - tokio::task::spawn_blocking(move || { - let $domain = git::GitDomain::from_model(repo_clone)?; - $body - }) - .await - .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? - .map_err(AppError::from) - }}; -} +use crate::git_spawn; impl AppService { /// Check and enforce branch protection rules before deleting (or renaming/moving away from) a branch. diff --git a/libs/service/git/commit.rs b/libs/service/git/commit.rs index 7b72dff..baf08ec 100644 --- a/libs/service/git/commit.rs +++ b/libs/service/git/commit.rs @@ -461,18 +461,7 @@ pub struct CommitDiffQuery { pub oid: String, } -macro_rules! git_spawn { - ($repo:expr, $domain:ident -> $body:expr) => {{ - let repo_clone = $repo.clone(); - tokio::task::spawn_blocking(move || { - let $domain = git::GitDomain::from_model(repo_clone)?; - $body - }) - .await - .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? - .map_err(AppError::from) - }}; -} +use crate::git_spawn; impl AppService { pub async fn git_commit_get( diff --git a/libs/service/git/repo.rs b/libs/service/git/repo.rs index be509d7..edd0144 100644 --- a/libs/service/git/repo.rs +++ b/libs/service/git/repo.rs @@ -63,6 +63,9 @@ impl From for ConfigSnapshotResponse { pub struct GitUpdateRepoRequest { pub default_branch: Option, pub ai_code_review_enabled: Option, + pub name: Option, + pub description: Option, + pub is_private: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct ConfigBoolResponse { @@ -463,6 +466,15 @@ impl AppService { if let Some(ai_enabled) = params.ai_code_review_enabled { active.ai_code_review_enabled = Set(ai_enabled); } + if let Some(name) = params.name { + active.repo_name = Set(name); + } + if let Some(description) = params.description { + active.description = Set(Some(description)); + } + if let Some(is_private) = params.is_private { + active.is_private = Set(is_private); + } active.update(&txn).await?; txn.commit().await?; diff --git a/libs/service/git/star.rs b/libs/service/git/star.rs index 09d0b7b..f0b2137 100644 --- a/libs/service/git/star.rs +++ b/libs/service/git/star.rs @@ -174,13 +174,13 @@ impl AppService { .utils_find_repo(namespace, repo_name.clone(), ctx) .await?; let page = std::cmp::Ord::max(pager.page, 1); - let par_page = std::cmp::Ord::min(std::cmp::Ord::max(pager.par_page, 1), 1000); - let offset_val = (page - 1).saturating_mul(par_page); + let per_page = std::cmp::Ord::min(std::cmp::Ord::max(pager.per_page, 1), 1000); + let offset_val = (page - 1).saturating_mul(per_page); let offset = offset_val as u64; let stars = RepoStar::find() .filter(repo_star::Column::Repo.eq(repo.id)) .order_by_desc(repo_star::Column::CreatedAt) - .limit(par_page as u64) + .limit(per_page as u64) .offset(offset) .all(&self.db) .await?; diff --git a/libs/service/git/tag.rs b/libs/service/git/tag.rs index dda52b9..3e7dcd8 100644 --- a/libs/service/git/tag.rs +++ b/libs/service/git/tag.rs @@ -251,8 +251,13 @@ impl AppService { // Fallback to git let cache_key = format!("git:tag:get:{}:{}:{}", namespace, repo_name, query.name); - let domain = git::GitDomain::from_model(repo)?; - let info = domain.tag_get(&query.name)?; + let name = query.name.clone(); + let info = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + domain.tag_get(&name) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let response = TagInfoResponse::from(info); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn @@ -385,25 +390,33 @@ impl AppService { .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let repo_id = repo.id; - let domain = git::GitDomain::from_model(repo)?; - let tagger = git::CommitSignature { - name: request - .tagger_name - .unwrap_or_else(|| "Anonymous".to_string()), - email: request - .tagger_email - .unwrap_or_else(|| "anonymous@example.com".to_string()), - time_secs: chrono::Utc::now().timestamp(), - offset_minutes: 0, - }; - let target = git::CommitOid::new(&request.target); - let info = domain.tag_create( - &request.name, - &target, - request.message.as_deref().unwrap_or(""), - &tagger, - request.force, - )?; + let tagger_name = request.tagger_name.clone(); + let tagger_email = request.tagger_email.clone(); + let req_name = request.name.clone(); + let req_target = request.target.clone(); + let req_message = request.message.clone(); + let req_force = request.force; + let info = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let tagger = git::CommitSignature { + name: tagger_name + .unwrap_or_else(|| "Anonymous".to_string()), + email: tagger_email + .unwrap_or_else(|| "anonymous@example.com".to_string()), + time_secs: chrono::Utc::now().timestamp(), + offset_minutes: 0, + }; + let target = git::CommitOid::new(&req_target); + domain.tag_create( + &req_name, + &target, + req_message.as_deref().unwrap_or(""), + &tagger, + req_force, + ) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; if let Ok(mut conn) = self.cache.conn().await { let key = format!("git:tag:list:{}:{}", namespace, repo_name); if let Err(e) = conn.del::(key).await { @@ -449,9 +462,16 @@ impl AppService { .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let repo_id = repo.id; - let domain = git::GitDomain::from_model(repo)?; - let target = git::CommitOid::new(&request.target); - let info = domain.tag_create_lightweight(&request.name, &target, request.force)?; + let lw_name = request.name.clone(); + let lw_target = request.target.clone(); + let lw_force = request.force; + let info = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let target = git::CommitOid::new(&lw_target); + domain.tag_create_lightweight(&lw_name, &target, lw_force) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; if let Ok(mut conn) = self.cache.conn().await { let key = format!("git:tag:list:{}:{}", namespace, repo_name); if let Err(e) = conn.del::(key).await { @@ -497,8 +517,13 @@ impl AppService { .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let repo_id = repo.id; - let domain = git::GitDomain::from_model(repo)?; - domain.tag_delete(&query.name)?; + let del_name = query.name.clone(); + tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + domain.tag_delete(&del_name) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; if let Ok(mut conn) = self.cache.conn().await { let list_key = format!("git:tag:list:{}:{}", namespace, repo_name); let get_key = format!("git:tag:get:{}:{}:{}", namespace, repo_name, query.name); @@ -548,8 +573,14 @@ impl AppService { .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let repo_id = repo.id; - let domain = git::GitDomain::from_model(repo)?; - let info = domain.tag_rename(&query.old_name, &query.new_name)?; + let old_name = query.old_name.clone(); + let new_name = query.new_name.clone(); + let info = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + domain.tag_rename(&old_name, &new_name) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; if let Ok(mut conn) = self.cache.conn().await { let list_key = format!("git:tag:list:{}:{}", namespace, repo_name); let old_key = format!("git:tag:get:{}:{}:{}", namespace, repo_name, query.old_name); @@ -609,14 +640,22 @@ impl AppService { .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; let _repo_id = repo.id; - let domain = git::GitDomain::from_model(repo)?; - let tagger = git::CommitSignature { - name: request.tagger_name, - email: request.tagger_email, - time_secs: chrono::Utc::now().timestamp(), - offset_minutes: 0, - }; - let info = domain.tag_update_message(&request.name, &request.message, &tagger)?; + let um_name = request.name.clone(); + let um_message = request.message.clone(); + let um_tagger_name = request.tagger_name.clone(); + let um_tagger_email = request.tagger_email.clone(); + let info = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let tagger = git::CommitSignature { + name: um_tagger_name, + email: um_tagger_email, + time_secs: chrono::Utc::now().timestamp(), + offset_minutes: 0, + }; + domain.tag_update_message(&um_name, &um_message, &tagger) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; if let Ok(mut conn) = self.cache.conn().await { let list_key = format!("git:tag:list:{}:{}", namespace, repo_name); let get_key = format!("git:tag:get:{}:{}:{}", namespace, repo_name, request.name); diff --git a/libs/service/git/tree.rs b/libs/service/git/tree.rs index 8d92318..e943ce8 100644 --- a/libs/service/git/tree.rs +++ b/libs/service/git/tree.rs @@ -9,7 +9,10 @@ use session::Session; pub struct TreeGetQuery { #[serde(default)] pub oid: String, + #[serde(default = "default_tree_limit")] + pub limit: usize, } +fn default_tree_limit() -> usize { 1000 } #[derive(Debug, Clone, Deserialize)] pub struct TreeEntryQuery { @@ -182,7 +185,7 @@ impl AppService { .map_err(AppError::from)?; let response: Vec = - entries.into_iter().map(TreeEntryResponse::from).collect(); + entries.into_iter().take(query.limit).map(TreeEntryResponse::from).collect(); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn diff --git a/libs/service/git/watch.rs b/libs/service/git/watch.rs index 1a320e7..e919163 100644 --- a/libs/service/git/watch.rs +++ b/libs/service/git/watch.rs @@ -179,13 +179,13 @@ impl AppService { ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let page = std::cmp::Ord::max(pager.page, 1); - let par_page = std::cmp::Ord::min(std::cmp::Ord::max(pager.par_page, 1), 1000); - let offset_val = (page - 1).saturating_mul(par_page); + let per_page = std::cmp::Ord::min(std::cmp::Ord::max(pager.per_page, 1), 1000); + let offset_val = (page - 1).saturating_mul(per_page); let offset = offset_val as u64; let watches: Vec = RepoWatch::find() .filter(repo_watch::Column::Repo.eq(repo.id)) .order_by_desc(repo_watch::Column::CreatedAt) - .limit(par_page as u64) + .limit(per_page as u64) .offset(offset) .all(&self.db) .await?; diff --git a/libs/service/issue/comment.rs b/libs/service/issue/comment.rs index d29e2e6..4b100f7 100644 --- a/libs/service/issue/comment.rs +++ b/libs/service/issue/comment.rs @@ -231,6 +231,7 @@ impl AppService { ) .await; + observability::incr!(observability::ISSUE_COMMENTS_CREATED_TOTAL); Ok(IssueCommentResponse { author_username: actor_username, ..IssueCommentResponse::from(model) diff --git a/libs/service/issue/issue.rs b/libs/service/issue/issue.rs index 9b84d55..0fb47ba 100644 --- a/libs/service/issue/issue.rs +++ b/libs/service/issue/issue.rs @@ -1,8 +1,8 @@ -use crate::AppService; use crate::error::AppError; +use crate::AppService; use chrono::Utc; use models::issues::{ - IssueState, issue, issue_assignee, issue_comment, issue_label, issue_repo, issue_subscriber, + issue, issue_assignee, issue_comment, issue_label, issue_repo, issue_subscriber, IssueState, }; use models::projects::project_members; use models::users::user; @@ -39,6 +39,7 @@ pub struct IssueResponse { pub author: Uuid, pub author_username: Option, pub milestone: Option, + pub labels: Vec, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, pub closed_at: Option>, @@ -57,6 +58,7 @@ impl From for IssueResponse { author: i.author, author_username: None, milestone: i.milestone, + labels: vec![], created_at: i.created_at, updated_at: i.updated_at, closed_at: i.closed_at, @@ -126,6 +128,27 @@ impl AppService { .await? }; + // Fetch labels for all issues in batch + let issue_ids: Vec = issues.iter().map(|i| i.id).collect(); + let issue_labels = if issue_ids.is_empty() { + vec![] + } else { + issue_label::Entity::find() + .filter(issue_label::Column::Issue.is_in(issue_ids)) + .all(&self.db) + .await? + }; + + let label_ids: Vec = issue_labels.iter().map(|il| il.label).collect(); + let labels = if label_ids.is_empty() { + vec![] + } else { + models::system::label::Entity::find() + .filter(models::system::label::Column::Id.is_in(label_ids)) + .all(&self.db) + .await? + }; + let responses: Vec = issues .into_iter() .map(|i| { @@ -133,8 +156,25 @@ impl AppService { .iter() .find(|u| u.uid == i.author) .map(|u| u.username.clone()); + + let i_labels: Vec = issue_labels + .iter() + .filter(|il| il.issue == i.id) + .map(|il| { + let lbl = labels.iter().find(|l| l.id == il.label); + super::label::IssueLabelResponse { + issue: il.issue, + label_id: il.label, + label_name: lbl.map(|l| l.name.clone()), + label_color: lbl.map(|l| l.color.clone()), + relation_at: il.relation_at, + } + }) + .collect(); + IssueResponse { author_username: username, + labels: i_labels, ..IssueResponse::from(i) } }) @@ -183,8 +223,39 @@ impl AppService { .flatten(); let username = author.map(|u| u.username); + // Fetch labels for the issue + let issue_labels = issue_label::Entity::find() + .filter(issue_label::Column::Issue.eq(issue.id)) + .all(&self.db) + .await?; + + let label_ids: Vec = issue_labels.iter().map(|il| il.label).collect(); + let labels = if label_ids.is_empty() { + vec![] + } else { + models::system::label::Entity::find() + .filter(models::system::label::Column::Id.is_in(label_ids)) + .all(&self.db) + .await? + }; + + let i_labels: Vec = issue_labels + .into_iter() + .map(|il| { + let lbl = labels.iter().find(|l| l.id == il.label); + super::label::IssueLabelResponse { + issue: il.issue, + label_id: il.label, + label_name: lbl.map(|l| l.name.clone()), + label_color: lbl.map(|l| l.color.clone()), + relation_at: il.relation_at, + } + }) + .collect(); + let response = IssueResponse { author_username: username, + labels: i_labels, ..IssueResponse::from(issue) }; @@ -222,7 +293,9 @@ impl AppService { ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let project = self.utils_find_project_by_name(project_name.clone()).await?; + let project = self + .utils_find_project_by_name(project_name.clone()) + .await?; // Any project member can create issues let member = project_members::Entity::find() @@ -299,12 +372,16 @@ impl AppService { let issue_body = model.body.clone(); let es = embed.clone(); tokio::spawn(async move { - if let Err(e) = es.embed_issue_chunked(&issue_id, &issue_title, issue_body.as_deref()).await { + if let Err(e) = es + .embed_issue_chunked(&issue_id, &issue_title, issue_body.as_deref()) + .await + { tracing::warn!(error = %e, issue_id = %issue_id, "failed to embed issue"); } }); } + observability::incr!(observability::ISSUES_OPENED_TOTAL); Ok(IssueResponse::from(model)) } @@ -379,6 +456,7 @@ impl AppService { tracing::warn!(error = ?e, "failed to log issue update activity"); } + observability::incr!(observability::ISSUES_UPDATED_TOTAL); Ok(IssueResponse::from(model)) } @@ -487,6 +565,11 @@ impl AppService { tracing::warn!(error = ?e, "failed to log issue state change activity"); } + if state == IssueState::Closed { + observability::incr!(observability::ISSUES_CLOSED_TOTAL); + } else { + observability::incr!(observability::ISSUES_REOPENED_TOTAL); + } Ok(IssueResponse::from(model)) } @@ -582,6 +665,7 @@ impl AppService { tracing::warn!(error = ?e, "failed to log issue delete activity"); } + observability::incr!(observability::ISSUES_DELETED_TOTAL); Ok(()) } diff --git a/libs/service/issue/label.rs b/libs/service/issue/label.rs index a3314d7..5eed154 100644 --- a/libs/service/issue/label.rs +++ b/libs/service/issue/label.rs @@ -54,7 +54,7 @@ pub struct CreateLabelRequest { pub color: String, } -#[derive(Debug, Clone, Serialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct IssueLabelResponse { pub issue: Uuid, pub label_id: i64, diff --git a/libs/service/lib.rs b/libs/service/lib.rs index 5af3984..5464e2c 100644 --- a/libs/service/lib.rs +++ b/libs/service/lib.rs @@ -4,13 +4,15 @@ use ::agent::chat::ChatService; use ::agent::client::AiClientConfig; use ::agent::task::service::TaskService; use ::agent::tool::ToolRegistry; -use ::agent::{EmbedService, new_embed_client}; +use ::agent::{new_embed_client, EmbedService}; use avatar::AppAvatar; use config::AppConfig; use db::cache::AppCache; use db::database::AppDatabase; use email::AppEmail; -use queue::{start_email_worker, EmailEnvelope, EmailSendFn, EmailSendFut, MessageProducer, NatsClient}; +use queue::{ + start_email_worker, EmailEnvelope, EmailSendFn, EmailSendFut, MessageProducer, NatsClient, +}; use room::metrics::RoomMetrics; use room::RoomService; use serde::{Deserialize, Serialize}; @@ -20,7 +22,7 @@ use ws_token::WsTokenService; pub mod storage; pub use storage::AppStorage; pub mod push; -pub use push::{WebPushService, PushPayload}; +pub use push::{PushPayload, WebPushService}; pub mod push_helper; #[derive(Clone)] @@ -37,6 +39,7 @@ pub struct AppService { pub push: Option>, pub embed_service: Option>, pub nats: Option>, + pub chat_service: Option>, } impl AppService { @@ -44,12 +47,7 @@ impl AppService { /// Reads the user's push subscription from `user_notification` table. /// Non-blocking: failures are logged but don't affect the caller. pub fn send_push_to_user(&self, user_id: uuid::Uuid, payload: PushPayload) { - push_helper::spawn_push_notification( - self.push.clone(), - self.db.clone(), - user_id, - payload, - ); + push_helper::spawn_push_notification(self.push.clone(), self.db.clone(), user_id, payload); } } @@ -78,16 +76,9 @@ impl AppService { } }; - let push = match ( - config.vapid_public_key(), - config.vapid_private_key(), - ) { + let push = match (config.vapid_public_key(), config.vapid_private_key()) { (Some(public_key), Some(private_key)) => { - match WebPushService::new( - public_key, - private_key, - config.vapid_sender_email(), - ) { + match WebPushService::new(public_key, private_key, config.vapid_sender_email()) { Ok(s) => { tracing::info!("WebPush initialized"); Some(Arc::new(s)) @@ -135,30 +126,22 @@ impl AppService { )); // Build EmbedService if Qdrant and embedding model are configured (graceful degradation) - let embed_service: Option> = - match new_embed_client(&config).await { - Ok(client) => { - let model_name = config - .get_embed_model_name() - .unwrap_or_else(|_| "text-embedding-3-small".into()); - let dimensions = config - .get_embed_model_dimensions() - .unwrap_or(1536); - let svc = EmbedService::new( - client, - db.writer().clone(), - model_name, - dimensions, - ); - let _ = svc.ensure_collections().await; - tracing::info!("EmbedService initialized (Qdrant + embeddings)"); - Some(Arc::new(svc)) - } - Err(e) => { - tracing::warn!(error = %e, "EmbedService not available - vector search disabled"); - None - } - }; + let embed_service: Option> = match new_embed_client(&config).await { + Ok(client) => { + let model_name = config + .get_embed_model_name() + .unwrap_or_else(|_| "text-embedding-3-small".into()); + let dimensions = config.get_embed_model_dimensions().unwrap_or(1536); + let svc = EmbedService::new(client, db.writer().clone(), model_name, dimensions); + let _ = svc.ensure_collections().await; + tracing::info!("EmbedService initialized (Qdrant + embeddings)"); + Some(Arc::new(svc)) + } + Err(e) => { + tracing::warn!(error = %e, "EmbedService not available - vector search disabled"); + None + } + }; let embed_service_for_app = embed_service.clone(); @@ -172,6 +155,7 @@ impl AppService { fctool::git_tools::register_all(&mut registry); fctool::file_tools::register_all(&mut registry); fctool::project_tools::register_all(&mut registry); + fctool::chat_tools::register_all(&mut registry); let mut chat_svc = ChatService::new() .with_ai_client_config(ai_client_config) .with_tool_registry(registry); @@ -202,7 +186,7 @@ impl AppService { message_producer.clone(), room_manager, nats.clone(), - chat_service, + chat_service.clone(), Some(task_service.clone()), None, push_fn, @@ -225,6 +209,7 @@ impl AppService { push, embed_service: embed_service_for_app, nats, + chat_service, }) } @@ -259,6 +244,7 @@ impl AppService { pub mod agent; pub mod auth; +pub mod chat; pub mod error; pub mod git; pub mod issue; @@ -268,11 +254,11 @@ pub mod search; pub mod skill; pub mod user; pub mod utils; -pub mod workspace; pub mod ws_token; #[derive(Debug, Clone, Deserialize, Serialize, ToSchema)] pub struct Pager { pub page: i64, - pub par_page: i64, -} \ No newline at end of file + #[serde(alias = "par_page")] + pub per_page: i64, +} diff --git a/libs/service/project/billing.rs b/libs/service/project/billing.rs index 0c220b4..5606c58 100644 --- a/libs/service/project/billing.rs +++ b/libs/service/project/billing.rs @@ -2,6 +2,7 @@ use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Datelike, NaiveDate, Utc}; use models::Decimal; +use models::ai::billing_error; use models::projects::{project_billing, project_billing_history}; use sea_orm::sea_query::prelude::rust_decimal::prelude::ToPrimitive; use sea_orm::*; @@ -10,12 +11,12 @@ use session::Session; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; -const DEFAULT_PROJECT_MONTHLY_CREDIT: f64 = 10.0; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ProjectBillingCurrentResponse { pub project_uid: Uuid, pub currency: String, + pub is_pro: bool, pub monthly_quota: f64, pub balance: f64, pub month_used: f64, @@ -82,7 +83,8 @@ impl AppService { Ok(ProjectBillingCurrentResponse { project_uid: project.id, currency: billing.currency, - monthly_quota: DEFAULT_PROJECT_MONTHLY_CREDIT, + is_pro: billing.is_pro, + monthly_quota: billing.monthly_quota.to_f64().unwrap_or_default(), balance: billing.balance.to_f64().unwrap_or_default(), month_used: month_used.to_f64().unwrap_or_default(), cycle_start_utc: month_start, @@ -150,14 +152,14 @@ impl AppService { } let now_utc = Utc::now(); - // Only first project per user gets initial budget ($10) + // Only first project per user gets initial budget ($20) let initial_balance = if let Some(uid) = user_uid { - let existing_projects = models::projects::project::Entity::find() + let existing_count = models::projects::project::Entity::find() .filter(models::projects::project::Column::CreatedBy.eq(uid)) - .all(&self.db) + .count(&self.db) .await?; - if existing_projects.len() <= 1 { - Decimal::from_f64_retain(DEFAULT_PROJECT_MONTHLY_CREDIT).unwrap_or(Decimal::ZERO) + if existing_count == 0 { + Decimal::new(200_000, 4) // $20.0000 } else { Decimal::ZERO } @@ -178,6 +180,56 @@ impl AppService { } } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BillingErrorItem { + pub id: Uuid, + pub scope: String, + pub scope_id: Uuid, + pub error_type: String, + pub message: String, + pub details: Option, + pub resolved: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BillingErrorsResponse { + pub list: Vec, +} + +impl AppService { + /// Fetch unresolved billing errors for a project. + pub async fn project_billing_errors( + &self, + ctx: &Session, + project_name: String, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self.utils_find_project_by_name(project_name).await?; + self.check_project_access(project.id, user_uid).await?; + + let errors = billing_error::Entity::find() + .filter(billing_error::Column::ScopeId.eq(project.id)) + .filter(billing_error::Column::Resolved.eq(false)) + .order_by_desc(billing_error::Column::CreatedAt) + .all(&self.db) + .await?; + + Ok(BillingErrorsResponse { + list: errors.into_iter().map(|e| BillingErrorItem { + id: e.id, + scope: e.scope, + scope_id: e.scope_id, + error_type: e.error_type, + message: e.message, + details: e.details, + resolved: e.resolved, + created_at: e.created_at, + }).collect(), + }) + } +} + fn utc_month_bounds(now_utc: DateTime) -> Result<(DateTime, DateTime), AppError> { let year = now_utc.year(); let month = now_utc.month(); diff --git a/libs/service/project/init.rs b/libs/service/project/init.rs index 8c7dd77..f4416ef 100644 --- a/libs/service/project/init.rs +++ b/libs/service/project/init.rs @@ -1,23 +1,18 @@ use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Utc}; -use models::Decimal; -use models::projects::{MemberRole, project, project_audit_log, project_billing, project_members}; -use models::workspaces::workspace_membership; +use models::projects::{MemberRole, project, project_audit_log, project_members}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; -const DEFAULT_PROJECT_INITIAL_BALANCE: f64 = 10.0; - #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ProjectInitParams { pub name: String, + pub display_name: Option, pub description: Option, pub is_public: bool, - /// Optional workspace slug to associate this project with. - pub workspace_slug: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] @@ -34,7 +29,6 @@ pub struct ProjectModel { pub avatar_url: Option, pub description: Option, pub is_public: bool, - pub workspace_id: Option, pub created_by: Uuid, pub created_at: DateTime, pub updated_at: DateTime, @@ -53,35 +47,16 @@ impl AppService { let user = ctx.user().ok_or(AppError::Unauthorized)?; let user = self.utils_find_user_by_uid(user).await?; - // Resolve workspace if provided - let workspace_id = match ¶ms.workspace_slug { - Some(slug) => { - let ws = self.utils_find_workspace_by_slug(slug.clone()).await?; - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user.uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await?; - if membership.is_none() { - return Err(AppError::NotWorkspaceMember); - } - Some(ws.id) - } - None => None, - }; - let project_uid = Uuid::now_v7(); let txn = self.db.begin().await?; let project = project::ActiveModel { id: Set(project_uid), name: Set(params.name.clone()), - display_name: Set(params.name), + display_name: Set(params.display_name.unwrap_or(params.name.clone())), avatar_url: Set(None), description: Set(params.description), is_public: Set(params.is_public), created_by: Set(user.uid), - workspace_id: Set(workspace_id), created_at: Set(Utc::now()), updated_at: Set(Utc::now()), }; @@ -96,28 +71,6 @@ impl AppService { }; project_member.insert(&txn).await?; - // Only first project per user gets initial budget - let existing_projects = project::Entity::find() - .filter(project::Column::CreatedBy.eq(user.uid)) - .all(&self.db) - .await?; - let initial_balance = if existing_projects.is_empty() { - Decimal::from_f64_retain(DEFAULT_PROJECT_INITIAL_BALANCE).unwrap_or(Decimal::ZERO) - } else { - Decimal::ZERO - }; - - let billing = project_billing::ActiveModel { - project: Set(_project.id), - balance: Set(initial_balance), - currency: Set("USD".to_string()), - user: Set(Some(user.uid)), - updated_at: Set(Utc::now()), - created_at: Set(Utc::now()), - ..Default::default() - }; - billing.insert(&txn).await?; - let log = project_audit_log::ActiveModel { project: Set(_project.id), actor: Set(user.uid), @@ -134,6 +87,14 @@ impl AppService { log.insert(&txn).await?; txn.commit().await?; + + observability::incr!(observability::PROJECTS_CREATED_TOTAL); + + // Initialize project billing ($20 for first project, $0 otherwise) + if let Err(e) = agent::billing::initialize_project_billing(&self.db, _project.id, user.uid).await { + tracing::warn!(project_id = %_project.id, error = %e, "Failed to initialize project billing — non-critical, continuing"); + } + Ok(ProjectInitResponse { params: inner, project: ProjectModel { @@ -143,7 +104,6 @@ impl AppService { avatar_url: _project.avatar_url.clone(), description: _project.description.clone(), is_public: _project.is_public, - workspace_id: _project.workspace_id, created_by: _project.created_by, created_at: _project.created_at, updated_at: _project.updated_at, diff --git a/libs/service/project/like.rs b/libs/service/project/like.rs index fc0bcc9..bf0d350 100644 --- a/libs/service/project/like.rs +++ b/libs/service/project/like.rs @@ -71,6 +71,7 @@ impl AppService { }; log.insert(&self.db).await?; + observability::incr!(observability::PROJECT_LIKES_TOTAL); Ok(()) } @@ -130,6 +131,7 @@ impl AppService { }; log.insert(&self.db).await?; + observability::incr!(observability::PROJECT_UNLIKES_TOTAL); Ok(()) } @@ -171,8 +173,8 @@ impl AppService { let likes = project_like::Entity::find() .filter(project_like::Column::Project.eq(project.id)) .order_by_desc(project_like::Column::CreatedAt) - .limit(pager.par_page as u64) - .offset(((pager.page - 1) * pager.par_page) as u64) + .limit(pager.per_page as u64) + .offset(((pager.page - 1) * pager.per_page) as u64) .all(&self.db) .await?; diff --git a/libs/service/project/members.rs b/libs/service/project/members.rs index ad7fae9..652d366 100644 --- a/libs/service/project/members.rs +++ b/libs/service/project/members.rs @@ -35,6 +35,42 @@ pub struct UpdateMemberRoleRequest { pub scope: MemberRole, } +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct MemberGroup { + pub role: String, + pub members: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct GroupedMemberListResponse { + pub groups: Vec, + pub total: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct RolePriorityInfo { + pub id: i64, + pub role_key: String, + pub display_name: String, + pub priority: i32, + pub color: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct RolePriorityListResponse { + pub roles: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] +pub struct UpsertRolePriorityRequest { + pub role_key: String, + pub display_name: String, + pub priority: i32, + pub color: Option, +} + impl AppService { pub async fn project_get_members( &self, @@ -106,6 +142,83 @@ impl AppService { }) } + pub async fn project_get_members_grouped( + &self, + project_name: String, + ctx: &Session, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self + .utils_find_project_by_name(project_name) + .await?; + + let _requester_member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(user_uid)) + .one(&self.db) + .await?; + + let members = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .order_by_asc(project_members::Column::JoinedAt) + .all(&self.db) + .await?; + + let total = members.len() as u64; + + let user_ids: Vec = members.iter().map(|m| m.user).collect(); + + let users_data = if user_ids.is_empty() { + vec![] + } else { + user::Entity::find() + .filter(user::Column::Uid.is_in(user_ids)) + .all(&self.db) + .await? + }; + + let member_infos: Vec = members + .into_iter() + .filter_map(|member| { + let role = member.scope_role().ok()?; + users_data + .iter() + .find(|u| u.uid == member.user) + .map(|user| MemberInfo { + user_id: user.uid, + username: user.username.clone(), + display_name: user.display_name.clone(), + avatar_url: user.avatar_url.clone(), + scope: role.clone(), + joined_at: member.joined_at, + }) + }) + .collect(); + + let mut groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for m in member_infos { + let role_str = m.scope.to_string(); + groups.entry(role_str).or_default().push(m); + } + + let role_priority = vec!["owner", "admin", "member"]; + let mut sorted_groups: Vec = groups + .into_iter() + .map(|(role, members)| MemberGroup { role, members }) + .collect(); + + sorted_groups.sort_by(|a, b| { + let pa = role_priority.iter().position(|&r| r == a.role).unwrap_or(99); + let pb = role_priority.iter().position(|&r| r == b.role).unwrap_or(99); + pa.cmp(&pb) + }); + + Ok(GroupedMemberListResponse { + groups: sorted_groups, + total, + }) + } + pub async fn project_update_member_role( &self, project_name: String, @@ -318,7 +431,6 @@ impl AppService { Ok(()) } - /// Add a user to all rooms in a project (used automatically when a user joins a project). /// Creates room_user_state entries for all public rooms + private rooms the user should access. pub async fn add_user_to_all_project_rooms( db: &impl ConnectionTrait, @@ -357,4 +469,135 @@ impl AppService { Ok(()) } + + pub async fn project_get_role_priorities( + &self, + project_name: String, + ctx: &Session, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self.utils_find_project_by_name(project_name).await?; + + let _member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(user_uid)) + .one(&self.db) + .await?; + + let roles = models::projects::ProjectRolePriority::find() + .filter(models::projects::project_role_priority::Column::Project.eq(project.id)) + .order_by_asc(models::projects::project_role_priority::Column::Priority) + .all(&self.db) + .await?; + + let infos: Vec = roles + .into_iter() + .map(|r| RolePriorityInfo { + id: r.id, + role_key: r.role_key, + display_name: r.display_name, + priority: r.priority, + color: r.color, + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect(); + + Ok(RolePriorityListResponse { roles: infos }) + } + + pub async fn project_upsert_role_priority( + &self, + project_name: String, + request: UpsertRolePriorityRequest, + ctx: &Session, + ) -> Result { + let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self.utils_find_project_by_name(project_name).await?; + + let actor_member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(actor_uid)) + .one(&self.db) + .await? + .ok_or(AppError::PermissionDenied)?; + + let actor_role = actor_member + .scope_role() + .map_err(|_| AppError::RoleParseError)?; + + if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin { + return Err(AppError::NoPower); + } + + let existing = models::projects::ProjectRolePriority::find() + .filter(models::projects::project_role_priority::Column::Project.eq(project.id)) + .filter(models::projects::project_role_priority::Column::RoleKey.eq(&request.role_key)) + .one(&self.db) + .await?; + + let model = if let Some(existing) = existing { + let mut active: models::projects::project_role_priority::ActiveModel = existing.into(); + active.display_name = Set(request.display_name); + active.priority = Set(request.priority); + active.color = Set(request.color); + active.updated_at = Set(Utc::now()); + active.update(&self.db).await? + } else { + let active = models::projects::project_role_priority::ActiveModel { + project: Set(project.id), + role_key: Set(request.role_key), + display_name: Set(request.display_name), + priority: Set(request.priority), + color: Set(request.color), + created_at: Set(Utc::now()), + updated_at: Set(Utc::now()), + ..Default::default() + }; + active.insert(&self.db).await? + }; + + Ok(RolePriorityInfo { + id: model.id, + role_key: model.role_key, + display_name: model.display_name, + priority: model.priority, + color: model.color, + created_at: model.created_at, + updated_at: model.updated_at, + }) + } + + pub async fn project_delete_role_priority( + &self, + project_name: String, + role_key: String, + ctx: &Session, + ) -> Result<(), AppError> { + let actor_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self.utils_find_project_by_name(project_name).await?; + + let actor_member = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(actor_uid)) + .one(&self.db) + .await? + .ok_or(AppError::PermissionDenied)?; + + let actor_role = actor_member + .scope_role() + .map_err(|_| AppError::RoleParseError)?; + + if actor_role != MemberRole::Owner && actor_role != MemberRole::Admin { + return Err(AppError::NoPower); + } + + models::projects::ProjectRolePriority::delete_many() + .filter(models::projects::project_role_priority::Column::Project.eq(project.id)) + .filter(models::projects::project_role_priority::Column::RoleKey.eq(role_key)) + .exec(&self.db) + .await?; + + Ok(()) + } } diff --git a/libs/service/project/mod.rs b/libs/service/project/mod.rs index 435d063..a5701e6 100644 --- a/libs/service/project/mod.rs +++ b/libs/service/project/mod.rs @@ -16,5 +16,6 @@ pub mod members; pub mod repo; pub mod settings; pub mod standard; +pub mod stats; pub mod transfer_repo; pub mod watch; diff --git a/libs/service/project/repo.rs b/libs/service/project/repo.rs index 987e3e8..c7f318a 100644 --- a/libs/service/project/repo.rs +++ b/libs/service/project/repo.rs @@ -190,16 +190,23 @@ impl AppService { if repo_ids.is_empty() { HashMap::new() } else { - let commits: Vec = RepoCommit::find() + let results: Vec<(Uuid, DateTime)> = RepoCommit::find() + .select_only() + .column(models::repos::repo_commit::Column::Repo) + .column_as( + models::repos::repo_commit::Column::CreatedAt.max(), + "max_created_at", + ) .filter(models::repos::repo_commit::Column::Repo.is_in(repo_ids.clone())) - .order_by_desc(models::repos::repo_commit::Column::CreatedAt) + .group_by(models::repos::repo_commit::Column::Repo) + .into_tuple::<(Uuid, DateTime)>() .all(&self.db) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; - let mut map: HashMap>> = HashMap::new(); - for commit in commits { - map.entry(commit.repo).or_insert_with(|| Some(commit.created_at)); - } + let mut map: HashMap>> = results + .into_iter() + .map(|(repo_id, dt)| (repo_id, Some(dt))) + .collect(); for repo_id in &repo_ids { map.entry(*repo_id).or_insert(None); } @@ -350,6 +357,7 @@ impl AppService { None, ).await; + observability::incr!(observability::REPOS_CREATED_TOTAL); Ok(ProjectRepoCreateResponse { uid: repo.id, repo_name: repo.repo_name, diff --git a/libs/service/project/stats.rs b/libs/service/project/stats.rs new file mode 100644 index 0000000..bdfc383 --- /dev/null +++ b/libs/service/project/stats.rs @@ -0,0 +1,302 @@ +use crate::AppService; +use crate::error::AppError; +use chrono::{DateTime, Utc}; +use models::projects::{project_activity, project_members}; +use models::repos::repo; +use models::rooms::{room, room_ai}; +use models::issues::issue; +use models::ai::ai_token_usage; +use models::users::user; +use sea_orm::*; +use serde::{Deserialize, Serialize}; +use session::Session; +use uuid::Uuid; + +/// Aggregated project statistics for dashboard display. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProjectStatsResponse { + pub project_id: Uuid, + pub project_name: String, + + // ── Membership ───────────────────────────────────────────── + pub member_count: i64, + pub my_role: Option, + + // ── Repos ────────────────────────────────────────────────── + pub repo_count: i64, + + // ── Issues ───────────────────────────────────────────────── + pub issue_total: i64, + pub issue_open: i64, + pub issue_closed: i64, + + // ── Pull Requests ────────────────────────────────────────── + pub pr_total: i64, + pub pr_open: i64, + pub pr_merged: i64, + pub pr_closed: i64, + + // ── Rooms ────────────────────────────────────────────────── + pub room_count: i64, + + // ── AI usage ─────────────────────────────────────────────── + pub ai_call_count: i64, + pub ai_input_tokens: i64, + pub ai_output_tokens: i64, + pub ai_cost_usd: Option, + + // ── Activity breakdown ───────────────────────────────────── + pub activity_last_30d: Vec, + pub recent_activities: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ActivityBreakdownItem { + pub event_type: String, + pub count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ProjectStatsActivityItem { + pub id: i64, + pub event_type: String, + pub title: String, + pub actor_name: String, + pub actor_avatar: Option, + pub created_at: DateTime, +} + +impl AppService { + /// Get aggregated project statistics for dashboard display. + pub async fn project_stats( + &self, + ctx: &Session, + project_name: String, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let project = self.utils_find_project_by_name(project_name).await?; + self.check_project_access(project.id, user_uid).await?; + + // ── Member count + role ──────────────────────────────── + let member_count = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .count(&self.db) + .await?; + + let my_role = project_members::Entity::find() + .filter(project_members::Column::Project.eq(project.id)) + .filter(project_members::Column::User.eq(user_uid)) + .one(&self.db) + .await? + .and_then(|m| m.scope_role().ok()); + + // ── Repo count ────────────────────────────────────────── + let repo_count = repo::Entity::find() + .filter(repo::Column::Project.eq(project.id)) + .count(&self.db) + .await?; + + // ── Issue counts ──────────────────────────────────────── + let issue_total = issue::Entity::find() + .filter(issue::Column::Project.eq(project.id)) + .count(&self.db) + .await?; + + let issue_open = issue::Entity::find() + .filter(issue::Column::Project.eq(project.id)) + .filter(issue::Column::State.eq("open")) + .count(&self.db) + .await?; + + let issue_closed = issue::Entity::find() + .filter(issue::Column::Project.eq(project.id)) + .filter(issue::Column::State.eq("closed")) + .count(&self.db) + .await?; + + // ── PR counts ─────────────────────────────────────────── + // Pull requests are linked through repos under this project. + let project_repo_ids: Vec = repo::Entity::find() + .filter(repo::Column::Project.eq(project.id)) + .all(&self.db) + .await? + .iter() + .map(|r| r.id) + .collect(); + + let (pr_total, pr_open, pr_merged, pr_closed) = if project_repo_ids.is_empty() { + (0i64, 0i64, 0i64, 0i64) + } else { + use models::pull_request::pull_request as pr; + + let pr_total = pr::Entity::find() + .filter(pr::Column::Repo.is_in(project_repo_ids.clone())) + .count(&self.db) + .await?; + + let pr_open = pr::Entity::find() + .filter(pr::Column::Repo.is_in(project_repo_ids.clone())) + .filter(pr::Column::Status.eq("open")) + .count(&self.db) + .await?; + + let pr_merged = pr::Entity::find() + .filter(pr::Column::Repo.is_in(project_repo_ids.clone())) + .filter(pr::Column::Status.eq("merged")) + .count(&self.db) + .await?; + + let pr_closed = pr::Entity::find() + .filter(pr::Column::Repo.is_in(project_repo_ids.clone())) + .filter(pr::Column::Status.eq("closed")) + .count(&self.db) + .await?; + + (pr_total as i64, pr_open as i64, pr_merged as i64, pr_closed as i64) + }; + + // ── Room count ────────────────────────────────────────── + let room_count = room::Entity::find() + .filter(room::Column::Project.eq(project.id)) + .count(&self.db) + .await?; + + // ── AI call count ─────────────────────────────────────── + // Sum call_count from all room_ai configs under this project's rooms. + let project_room_ids: Vec = room::Entity::find() + .filter(room::Column::Project.eq(project.id)) + .all(&self.db) + .await? + .iter() + .map(|r| r.id) + .collect(); + + let ai_call_count = if project_room_ids.is_empty() { + 0i64 + } else { + let room_ai_configs = room_ai::Entity::find() + .filter(room_ai::Column::Room.is_in(project_room_ids.clone())) + .all(&self.db) + .await?; + room_ai_configs.iter().map(|c| c.call_count).sum::() + }; + + // ── AI token usage ────────────────────────────────────── + // Sum token usage from ai_token_usage records for conversations in this project. + let ai_conversations = models::ai::ai_conversation::Entity::find() + .filter(models::ai::ai_conversation::Column::ProjectId.eq(project.id)) + .all(&self.db) + .await?; + let conv_ids: Vec = ai_conversations.iter().map(|c| c.id).collect(); + + let (ai_input_tokens, ai_output_tokens, ai_cost_usd) = if conv_ids.is_empty() { + (0i64, 0i64, None) + } else { + // Token usage may also be recorded without conversation_id (room AI) + // Query by user_id + project's members too, but that's too broad. + // Instead, aggregate by conversation_id AND room-AI records. + let token_records = ai_token_usage::Entity::find() + .filter(ai_token_usage::Column::ConversationId.is_in(conv_ids.clone())) + .all(&self.db) + .await?; + + let input_sum = token_records.iter().map(|t| t.input_tokens as i64).sum::(); + let output_sum = token_records.iter().map(|t| t.output_tokens as i64).sum::(); + let cost_sum: rust_decimal::Decimal = token_records + .iter() + .filter_map(|t| t.cost_usd) + .sum(); + + let cost_str = if cost_sum != rust_decimal::Decimal::ZERO { + Some(cost_sum.to_string()) + } else { + None + }; + + (input_sum, output_sum, cost_str) + }; + + // ── Activity breakdown last 30 days ───────────────────── + let thirty_days_ago = Utc::now() - chrono::Duration::days(30); + let activity_rows = project_activity::Entity::find() + .filter(project_activity::Column::Project.eq(project.id)) + .filter(project_activity::Column::CreatedAt.gte(thirty_days_ago)) + .filter(project_activity::Column::IsPrivate.eq(false)) + .all(&self.db) + .await?; + + // Group by event_type + let mut breakdown_map: std::collections::HashMap = std::collections::HashMap::new(); + for a in &activity_rows { + *breakdown_map.entry(a.event_type.clone()).or_insert(0) += 1; + } + let activity_last_30d: Vec = breakdown_map + .into_iter() + .map(|(event_type, count)| ActivityBreakdownItem { event_type, count }) + .collect(); + + // ── Recent activities (top 10) ────────────────────────── + let recent = project_activity::Entity::find() + .filter(project_activity::Column::Project.eq(project.id)) + .filter(project_activity::Column::IsPrivate.eq(false)) + .order_by_desc(project_activity::Column::CreatedAt) + .limit(10) + .all(&self.db) + .await?; + + // Enrich with actor info + let actor_ids: Vec = recent.iter().map(|a| a.actor).collect(); + let actor_map: std::collections::HashMap)> = if actor_ids.is_empty() { + std::collections::HashMap::new() + } else { + let users = user::Entity::find() + .filter(user::Column::Uid.is_in(actor_ids)) + .all(&self.db) + .await?; + users + .into_iter() + .map(|u| (u.uid, (u.display_name.or(Some(u.username)).unwrap_or_default(), u.avatar_url))) + .collect() + }; + + let recent_activities: Vec = recent + .into_iter() + .map(|a| { + let (actor_name, actor_avatar) = actor_map + .get(&a.actor) + .cloned() + .unwrap_or_else(|| ("Unknown".to_string(), None)); + ProjectStatsActivityItem { + id: a.id, + event_type: a.event_type, + title: a.title, + actor_name, + actor_avatar, + created_at: a.created_at, + } + }) + .collect(); + + Ok(ProjectStatsResponse { + project_id: project.id, + project_name: project.name, + member_count: member_count as i64, + my_role: my_role.map(|r| r.to_string()), + repo_count: repo_count as i64, + issue_total: issue_total as i64, + issue_open: issue_open as i64, + issue_closed: issue_closed as i64, + pr_total, + pr_open, + pr_merged, + pr_closed, + room_count: room_count as i64, + ai_call_count, + ai_input_tokens, + ai_output_tokens, + ai_cost_usd, + activity_last_30d, + recent_activities, + }) + } +} \ No newline at end of file diff --git a/libs/service/project/watch.rs b/libs/service/project/watch.rs index 579860b..af16f1e 100644 --- a/libs/service/project/watch.rs +++ b/libs/service/project/watch.rs @@ -75,6 +75,7 @@ impl AppService { }; log.insert(&self.db).await?; + observability::incr!(observability::PROJECT_WATCHES_TOTAL); Ok(()) } @@ -134,6 +135,7 @@ impl AppService { }; log.insert(&self.db).await?; + observability::incr!(observability::PROJECT_UNWATCHES_TOTAL); Ok(()) } @@ -175,8 +177,8 @@ impl AppService { let watches = ProjectWatch::find() .filter(project_watch::Column::Project.eq(project.id)) .order_by_desc(project_watch::Column::CreatedAt) - .limit(pager.par_page as u64) - .offset(((pager.page - 1) * pager.par_page) as u64) + .limit(pager.per_page as u64) + .offset(((pager.page - 1) * pager.per_page) as u64) .all(&self.db) .await?; diff --git a/libs/service/pull_request/merge.rs b/libs/service/pull_request/merge.rs index 7891619..6de57b5 100644 --- a/libs/service/pull_request/merge.rs +++ b/libs/service/pull_request/merge.rs @@ -98,14 +98,19 @@ impl AppService { .await? .ok_or(AppError::NotFound("Pull request not found".to_string()))?; - let domain = git::GitDomain::from_model(repo)?; - - let head_ref_name = resolve_ref_name(&pr.head); - let head_oid = domain - .ref_target(&head_ref_name)? - .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; - - let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?; + let head = pr.head.clone(); + let base = pr.base.clone(); + let (_head_oid, analysis, _pref) = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let head_ref_name = resolve_ref_name(&head); + let head_oid = domain + .ref_target(&head_ref_name)? + .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; + let (analysis, pref) = domain.merge_analysis_for_ref(&base, &head_oid)?; + Ok::<_, AppError>((head_oid, analysis, pref)) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let mut flags = Vec::new(); if analysis.is_fast_forward { @@ -156,30 +161,44 @@ impl AppService { .await? .ok_or(AppError::NotFound("Pull request not found".to_string()))?; - let domain = git::GitDomain::from_model(repo)?; - - let head_ref_name = resolve_ref_name(&pr.head); - let head_oid = domain - .ref_target(&head_ref_name)? - .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; - - let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?; - - let has_conflicts = - !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted(); - - if has_conflicts { - let conflicted_files = self.get_conflicted_files(&domain)?; - Ok(MergeConflictResponse { - has_conflicts: true, + let head = pr.head.clone(); + let base = pr.base.clone(); + let result = tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + let head_ref_name = resolve_ref_name(&head); + let head_oid = domain + .ref_target(&head_ref_name)? + .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; + let (analysis, _pref) = domain.merge_analysis_for_ref(&base, &head_oid)?; + let has_conflicts = + !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted(); + let conflicted_files = if has_conflicts { + let index = domain + .repo() + .index() + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + index + .conflicts() + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .filter_map(|result| result.ok()) + .filter_map(|conflict| { + conflict.our.as_ref().map(|entry| MergeConflictFile { + path: String::from_utf8_lossy(&entry.path).to_string(), + status: "both_modified".to_string(), + }) + }) + .collect() + } else { + vec![] + }; + Ok::<_, AppError>(MergeConflictResponse { + has_conflicts, conflicted_files, }) - } else { - Ok(MergeConflictResponse { - has_conflicts: false, - conflicted_files: vec![], - }) - } + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; + Ok(result) } /// ONLY admin/owner of the target repo can merge. @@ -249,71 +268,80 @@ impl AppService { } } - let domain = git::GitDomain::from_model(repo.clone())?; + let pr_head = pr.head.clone(); + let pr_base = pr.base.clone(); + let pr_title = pr.title.clone(); + let repo_for_domain = repo.clone(); + let fast_forward = request.fast_forward; + let strategy = request.strategy; + let message = request.message; - let head_ref_name = resolve_ref_name(&pr.head); - let head_oid = domain - .ref_target(&head_ref_name)? - .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; - let base_oid = domain - .ref_target(&resolve_ref_name(&pr.base))? - .ok_or_else(|| AppError::BadRequest("Base ref has no OID".to_string()))?; + tokio::task::spawn_blocking(move || -> Result<(), AppError> { + let domain = git::GitDomain::from_model(repo_for_domain)?; - let (analysis, _pref) = domain.merge_analysis_for_ref(&pr.base, &head_oid)?; + let head_ref_name = resolve_ref_name(&pr_head); + let head_oid = domain + .ref_target(&head_ref_name)? + .ok_or_else(|| AppError::BadRequest("Head ref has no OID".to_string()))?; + let base_oid = domain + .ref_target(&resolve_ref_name(&pr_base))? + .ok_or_else(|| AppError::BadRequest("Base ref has no OID".to_string()))?; - if !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted() { - return Err(AppError::BadRequest( - "Pull request has merge conflicts".to_string(), - )); - } + let (analysis, _pref) = domain.merge_analysis_for_ref(&pr_base, &head_oid)?; - // Build merge commit message - let merge_msg = if request.message == default_merge_message() { - format!("{} (#{})\n\n{}", pr.title, pr_number, pr.title) - } else { - request.message - }; + if !analysis.is_fast_forward && !analysis.is_up_to_date && domain.merge_is_conflicted() { + return Err(AppError::BadRequest( + "Pull request has merge conflicts".to_string(), + )); + } - // Get author signature for merge commit - let sig = domain.commit_default_signature()?; - let committer = sig.clone(); + let merge_msg = if message == default_merge_message() { + format!("{} (#{})\n\n{}", pr_title, pr_number, pr_title) + } else { + message + }; - if analysis.is_fast_forward && request.fast_forward { - // Fast-forward: move base ref forward to head - let base_ref_name = resolve_ref_name(&pr.base); - domain.ref_update(&base_ref_name, head_oid.clone(), None, None)?; - } else { - match request.strategy { - MergeStrategy::MergeCommit => { - domain.merge_commits(&base_oid, &head_oid, None)?; + let sig = domain.commit_default_signature()?; + let committer = sig.clone(); - // Write the merge commit from the merge index - let merge_oid = domain.commit_create_from_index( - None, - &sig, - &committer, - &merge_msg, - &[base_oid.clone(), head_oid.clone()], - )?; - let base_ref_name = resolve_ref_name(&pr.base); - domain.ref_update(&base_ref_name, merge_oid, None, None)?; - let _ = domain.merge_abort(); - } - MergeStrategy::Squash => { - // Squash all commits from source branch into one on top of base - let squash_oid = domain.squash_commits(&base_oid, &pr.head)?; - let base_ref_name = resolve_ref_name(&pr.base); - domain.ref_update(&base_ref_name, squash_oid, None, None)?; - } - MergeStrategy::Rebase => { - // Rebase source commits onto base - let rebase_oid = domain.rebase_commits(&base_oid, &head_oid)?; - let base_ref_name = resolve_ref_name(&pr.base); - domain.ref_update(&base_ref_name, rebase_oid, None, None)?; + if analysis.is_fast_forward && fast_forward { + let base_ref_name = resolve_ref_name(&pr_base); + domain.ref_update(&base_ref_name, head_oid.clone(), None, None)?; + } else { + match strategy { + MergeStrategy::MergeCommit => { + domain.merge_commits(&base_oid, &head_oid, None)?; + let merge_oid = domain.commit_create_from_index( + None, + &sig, + &committer, + &merge_msg, + &[base_oid.clone(), head_oid.clone()], + )?; + let base_ref_name = resolve_ref_name(&pr_base); + domain.ref_update(&base_ref_name, merge_oid, None, None)?; + let _ = domain.merge_abort(); + } + MergeStrategy::Squash => { + let squash_oid = domain.squash_commits(&base_oid, &pr_head)?; + let base_ref_name = resolve_ref_name(&pr_base); + domain.ref_update(&base_ref_name, squash_oid, None, None)?; + } + MergeStrategy::Rebase => { + let rebase_oid = domain.rebase_commits(&base_oid, &head_oid)?; + let base_ref_name = resolve_ref_name(&pr_base); + domain.ref_update(&base_ref_name, rebase_oid, None, None)?; + } } } - } + Ok(()) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; + // NOTE: Git operations have succeeded at this point — git refs are already modified. + // If the DB update below fails, the PR status won't reflect the actual git state. + // A recovery mechanism (idempotent retry or rollback) should be implemented. let now = Utc::now(); let mut active: pull_request::ActiveModel = pr.clone().into(); @@ -353,6 +381,7 @@ impl AppService { ) .await; + observability::incr!(observability::PRS_MERGED_TOTAL); Ok(MergeResponse { repo: repo.id, number: pr_number, @@ -380,8 +409,13 @@ impl AppService { .await? .ok_or(AppError::NotFound("Pull request not found".to_string()))?; - let domain = git::GitDomain::from_model(repo.clone())?; - domain.merge_abort()?; + let repo_for_abort = repo.clone(); + tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo_for_abort)?; + domain.merge_abort() + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))??; let user_uid = ctx.user().unwrap_or(Uuid::nil()); let _ = self @@ -423,11 +457,15 @@ impl AppService { .await? .ok_or(AppError::NotFound("Pull request not found".to_string()))?; - let domain = git::GitDomain::from_model(repo)?; - Ok(domain.merge_is_in_progress()) + tokio::task::spawn_blocking(move || { + let domain = git::GitDomain::from_model(repo)?; + Ok::<_, AppError>(domain.merge_is_in_progress()) + }) + .await + .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? } - fn get_conflicted_files( + fn _get_conflicted_files( &self, domain: &git::GitDomain, ) -> Result, AppError> { diff --git a/libs/service/pull_request/pull_request.rs b/libs/service/pull_request/pull_request.rs index 029048b..fb6ebf4 100644 --- a/libs/service/pull_request/pull_request.rs +++ b/libs/service/pull_request/pull_request.rs @@ -364,6 +364,7 @@ impl AppService { }); } + observability::incr!(observability::PRS_OPENED_TOTAL); Ok(PullRequestResponse::from(model)) } @@ -459,6 +460,7 @@ impl AppService { ) .await; + observability::incr!(observability::PRS_UPDATED_TOTAL); Ok(PullRequestResponse::from(model)) } @@ -699,6 +701,10 @@ impl AppService { ) .await; + if status == PrStatus::Closed { + observability::incr!(observability::PRS_CLOSED_TOTAL); + } + Ok(PullRequestResponse::from(model)) } diff --git a/libs/service/pull_request/review.rs b/libs/service/pull_request/review.rs index 558f871..12318e2 100644 --- a/libs/service/pull_request/review.rs +++ b/libs/service/pull_request/review.rs @@ -207,6 +207,7 @@ impl AppService { }, ) .await; + observability::incr!(observability::PR_REVIEWS_SUBMITTED_TOTAL); Ok(ReviewResponse { reviewer_username: username.clone(), ..ReviewResponse::from(model.clone()) diff --git a/libs/service/pull_request/review_comment.rs b/libs/service/pull_request/review_comment.rs index 276c9da..d2d5b3f 100644 --- a/libs/service/pull_request/review_comment.rs +++ b/libs/service/pull_request/review_comment.rs @@ -83,6 +83,10 @@ pub struct ReviewCommentListQuery { /// If false, only return general comments (no path). /// Omit to return all comments. pub file_only: Option, + #[serde(default)] + pub limit: Option, + #[serde(default)] + pub offset: Option, } /// A review comment thread: one root comment plus all its replies. @@ -132,9 +136,8 @@ impl AppService { stmt = stmt.filter(pull_request_review_comment::Column::Path.is_null()); } - let comments = stmt.all(&self.db).await?; - - let total = comments.len() as i64; + let total = stmt.clone().count(&self.db).await? as i64; + let comments = stmt.limit(query.limit.unwrap_or(200)).offset(query.offset.unwrap_or(0)).all(&self.db).await?; let author_ids: Vec = comments.iter().map(|c| c.author).collect(); let authors = if author_ids.is_empty() { diff --git a/libs/service/push.rs b/libs/service/push.rs index d6b1850..0cc47ae 100644 --- a/libs/service/push.rs +++ b/libs/service/push.rs @@ -57,7 +57,7 @@ impl WebPushService { auth: &str, payload: &PushPayload, ) -> anyhow::Result<()> { - let endpoint_uri: http::Uri = endpoint + let endpoint_uri: http1::Uri = endpoint .parse() .with_context(|| format!("Invalid endpoint URL: {}", endpoint))?; diff --git a/libs/service/push_helper.rs b/libs/service/push_helper.rs index 4d79a6d..984501a 100644 --- a/libs/service/push_helper.rs +++ b/libs/service/push_helper.rs @@ -1,7 +1,7 @@ use crate::push::{PushPayload, WebPushService}; use db::database::AppDatabase; use models::users::user_notification; -use sea_orm::EntityTrait; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use std::sync::Arc; use uuid::Uuid; @@ -14,20 +14,23 @@ pub async fn send_push_notification( user_id: Uuid, payload: &PushPayload, ) -> Result<(), String> { - let prefs = user_notification::Entity::find_by_id(user_id) + let prefs = user_notification::Entity::find() + .filter(user_notification::Column::User.eq(user_id)) .one(db) .await .map_err(|e| format!("Failed to read push subscription: {}", e))?; if let Some(prefs) = prefs { - if prefs.push_enabled - && let Some(endpoint) = &prefs.push_subscription_endpoint - && let Some(p256dh) = &prefs.push_subscription_keys_p256dh - && let Some(auth) = &prefs.push_subscription_keys_auth - { - push.send(endpoint, p256dh, auth, payload) - .await - .map_err(|e| format!("WebPush send failed: {}", e))?; + if prefs.push_enabled { + if let Some(endpoint) = &prefs.push_subscription_endpoint { + if let Some(p256dh) = &prefs.push_subscription_keys_p256dh { + if let Some(auth) = &prefs.push_subscription_keys_auth { + push.send(endpoint, p256dh, auth, payload) + .await + .map_err(|e| format!("WebPush send failed: {}", e))?; + } + } + } } } diff --git a/libs/service/user/billing.rs b/libs/service/user/billing.rs new file mode 100644 index 0000000..7580d67 --- /dev/null +++ b/libs/service/user/billing.rs @@ -0,0 +1,154 @@ +use crate::AppService; +use crate::error::AppError; +use chrono::{DateTime, Utc}; +use models::ai::billing_error; +use models::projects::project_billing_history; +use models::users::user_billing; +use sea_orm::sea_query::prelude::rust_decimal::prelude::ToPrimitive; +use sea_orm::*; +use serde::{Deserialize, Serialize}; +use session::Session; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserBillingResponse { + pub user_uid: Uuid, + pub balance: f64, + pub currency: String, + pub is_pro: bool, + pub monthly_quota: f64, + pub month_used: f64, + pub cycle_start_utc: Option>, + pub cycle_end_utc: Option>, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserBillingErrorsResponse { + pub list: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserBillingErrorItem { + pub id: Uuid, + pub scope: String, + pub scope_id: Uuid, + pub error_type: String, + pub message: String, + pub details: Option, + pub resolved: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] +pub struct UserBillingHistoryQuery { + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserBillingHistoryItem { + pub uid: Uuid, + pub project_uid: Uuid, + pub user_uid: Option, + pub amount: f64, + pub currency: String, + pub reason: String, + pub extra: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserBillingHistoryResponse { + pub page: u64, + pub per_page: u64, + pub total: u64, + pub list: Vec, +} + +impl AppService { + pub async fn user_billing_current( + &self, + ctx: &Session, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let billing = user_billing::Entity::find_by_id(user_uid) + .one(&self.db) + .await? + .ok_or_else(|| AppError::InternalServerError("User billing not found".into()))?; + + Ok(UserBillingResponse { + user_uid: billing.user, + balance: billing.balance.to_f64().unwrap_or_default(), + currency: billing.currency, + is_pro: billing.is_pro, + monthly_quota: billing.monthly_quota.to_f64().unwrap_or_default(), + month_used: billing.month_used.to_f64().unwrap_or_default(), + cycle_start_utc: billing.cycle_start, + cycle_end_utc: billing.cycle_end, + updated_at: billing.updated_at, + created_at: billing.created_at, + }) + } + + pub async fn user_billing_errors( + &self, + ctx: &Session, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let errors = billing_error::Entity::find() + .filter(billing_error::Column::Scope.eq("user")) + .filter(billing_error::Column::ScopeId.eq(user_uid)) + .filter(billing_error::Column::Resolved.eq(false)) + .order_by_desc(billing_error::Column::CreatedAt) + .all(&self.db) + .await?; + + Ok(UserBillingErrorsResponse { + list: errors.into_iter().map(|e| UserBillingErrorItem { + id: e.id, + scope: e.scope, + scope_id: e.scope_id, + error_type: e.error_type, + message: e.message, + details: e.details, + resolved: e.resolved, + created_at: e.created_at, + }).collect(), + }) + } + + pub async fn user_billing_history( + &self, + ctx: &Session, + query: UserBillingHistoryQuery, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let page = std::cmp::max(query.page.unwrap_or(1), 1); + let per_page = query.per_page.unwrap_or(20).clamp(1, 200); + + let paginator = project_billing_history::Entity::find() + .filter(project_billing_history::Column::User.eq(user_uid)) + .order_by_desc(project_billing_history::Column::CreatedAt) + .paginate(&self.db, per_page); + let total = paginator.num_items().await?; + let rows = paginator.fetch_page(page - 1).await?; + + let list = rows.into_iter().map(|x| UserBillingHistoryItem { + uid: x.uid, + project_uid: x.project, + user_uid: x.user, + amount: x.amount.to_f64().unwrap_or_default(), + currency: x.currency, + reason: x.reason, + extra: x.extra.map(|v| v.into()), + created_at: x.created_at, + }).collect(); + + Ok(UserBillingHistoryResponse { page, per_page, total, list }) + } +} \ No newline at end of file diff --git a/libs/service/user/chpc.rs b/libs/service/user/chpc.rs index ac5c793..dce8562 100644 --- a/libs/service/user/chpc.rs +++ b/libs/service/user/chpc.rs @@ -140,12 +140,14 @@ impl AppService { if let Ok(mut conn) = self.cache.conn().await { let today = Local::now().date_naive(); let two_years_ago = today - Duration::days(730); + let mut pipe = redis::Pipeline::new(); let mut current = two_years_ago; while current <= today { let key = self.build_heatmap_cache_key(&user_uid, current, current); - let _: Option<()> = conn.del::<_, ()>(key).await.ok(); + pipe.del(key); current += Duration::days(1); } + let _: () = pipe.query_async(&mut conn).await.ok().unwrap_or(()); } Ok(()) } diff --git a/libs/service/user/mod.rs b/libs/service/user/mod.rs index 5bf0af8..3bd2718 100644 --- a/libs/service/user/mod.rs +++ b/libs/service/user/mod.rs @@ -1,5 +1,6 @@ pub mod access_key; pub mod avatar; +pub mod billing; pub mod chpc; pub mod notification; pub mod notify; @@ -10,5 +11,6 @@ pub mod repository; pub mod ssh_key; pub mod stars; pub mod subscribe; +pub mod summary; pub mod user_activity; pub mod user_info; diff --git a/libs/service/user/projects.rs b/libs/service/user/projects.rs index 4bd339d..f749fda 100644 --- a/libs/service/user/projects.rs +++ b/libs/service/user/projects.rs @@ -80,7 +80,8 @@ impl AppService { // Union + dedup (preserving first occurrence order) let mut project_ids: Vec = created_projects; - let new_ids: Vec = member_projects.into_iter().filter(|id| !project_ids.contains(id)).collect(); + let project_id_set: std::collections::HashSet<&Uuid> = project_ids.iter().collect(); + let new_ids: Vec = member_projects.into_iter().filter(|id| !project_id_set.contains(id)).collect(); project_ids.extend(new_ids); let total_count = project_ids.len() as u64; @@ -124,16 +125,30 @@ impl AppService { std::collections::HashSet::new() }; + let member_counts: std::collections::HashMap = if !page_ids.is_empty() { + project_members::Entity::find() + .filter(project_members::Column::Project.is_in(page_ids.clone())) + .select_only() + .column_as(project_members::Column::Project, "project_id") + .column_as(project_members::Column::Id.count(), "count") + .group_by(project_members::Column::Project) + .into_tuple::<(Uuid, i64)>() + .all(&self.db) + .await + .unwrap_or_default() + .into_iter() + .collect() + } else { + std::collections::HashMap::new() + }; + let mut project_infos: Vec = Vec::new(); for project in sorted_projects { // Privacy: non-owners/non-admins only see public projects (member or created) if !is_owner && !has_admin_privilege && !project.is_public { continue; } - let member_count = project_members::Entity::find() - .filter(project_members::Column::Project.eq(project.id)) - .count(&self.db) - .await?; + let member_count = member_counts.get(&project.id).copied().unwrap_or(0); let is_member = user_project_memberships.contains(&project.id); diff --git a/libs/service/user/repository.rs b/libs/service/user/repository.rs index 89718ce..a3161f5 100644 --- a/libs/service/user/repository.rs +++ b/libs/service/user/repository.rs @@ -2,6 +2,7 @@ use crate::AppService; use crate::error::AppError; use chrono::Utc; use models::repos::repo; +use models::projects::project; use models::users::user; use sea_orm::prelude::*; use sea_orm::*; @@ -9,11 +10,13 @@ use serde::{Deserialize, Serialize}; use session::Session; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; +use std::collections::HashMap; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserRepoInfo { pub uid: Uuid, pub repo_name: String, + pub project_name: String, pub description: Option, pub default_branch: String, pub is_private: bool, @@ -78,11 +81,20 @@ impl AppService { .all(&self.db) .await?; + // Fetch project names for all repos + let project_ids: Vec<_> = repos.iter().map(|r| r.project).collect(); + let projects = project::Entity::find() + .filter(project::Column::Id.is_in(project_ids)) + .all(&self.db) + .await?; + let project_map: HashMap<_, _> = projects.into_iter().map(|p| (p.id, p.name)).collect(); + let repo_infos: Vec = repos .into_iter() .map(|r| UserRepoInfo { uid: r.id, repo_name: r.repo_name, + project_name: project_map.get(&r.project).cloned().unwrap_or_default(), description: r.description, default_branch: r.default_branch, is_private: r.is_private, diff --git a/libs/service/user/ssh_key.rs b/libs/service/user/ssh_key.rs index b9f836e..0a61a3e 100644 --- a/libs/service/user/ssh_key.rs +++ b/libs/service/user/ssh_key.rs @@ -147,6 +147,28 @@ impl AppService { Ok(SshKeyListResponse { keys, total }) } + pub async fn user_list_ssh_keys_by_username( + &self, + username: String, + ) -> Result { + let user = self.utils_find_user_by_username(username).await?; + + let keys: Vec = user_ssh_key::Entity::find() + .filter(user_ssh_key::Column::User.eq(user.uid)) + .filter(user_ssh_key::Column::IsRevoked.eq(false)) + .order_by_desc(user_ssh_key::Column::CreatedAt) + .all(&self.db) + .await?; + + let total = keys.len(); + let keys = keys + .into_iter() + .map(|k| self.user_model_to_response(k)) + .collect(); + + Ok(SshKeyListResponse { keys, total }) + } + pub async fn user_get_ssh_key( &self, context: &Session, diff --git a/libs/service/user/summary.rs b/libs/service/user/summary.rs new file mode 100644 index 0000000..d76d8e2 --- /dev/null +++ b/libs/service/user/summary.rs @@ -0,0 +1,57 @@ +use crate::AppService; +use crate::error::AppError; +use serde::{Deserialize, Serialize}; +use session::Session; +use utoipa::ToSchema; +use super::user_info::UserInfoExternal; +use super::repository::{UserRepoInfo, UserReposQuery}; +use super::projects::{UserProjectInfo, UserProjectsQuery}; +use super::user_activity::{UserActivityItem, UserActivityQuery}; +use super::chpc::{ContributionHeatmapResponse, ContributionHeatmapQuery}; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserSummaryResponse { + pub info: UserInfoExternal, + pub repos: Vec, + pub projects: Vec, + pub activity: Vec, + pub heatmap: ContributionHeatmapResponse, + pub follower_count: u64, + pub following_count: u64, + pub stars_count: u64, +} + +impl AppService { + pub async fn user_get_summary( + &self, + context: Session, + username: String, + ) -> Result { + let info = self.user_info(context.clone(), username.clone()).await?; + + let repos_resp = self.get_user_repos(context.clone(), username.clone(), UserReposQuery { page: Some(1), per_page: Some(4) }).await?; + + let projects_resp = self.get_user_projects(context.clone(), username.clone(), UserProjectsQuery { page: Some(1), per_page: Some(4) }).await?; + + let activity_resp = self.get_user_activity(context.clone(), username.clone(), UserActivityQuery { page: Some(1), per_page: Some(8) }).await?; + + let heatmap = self.get_user_contribution_heatmap(context.clone(), username.clone(), ContributionHeatmapQuery { start_date: None, end_date: None }).await?; + + let follower_count = self.user_get_subscriber_count(context.clone(), username.clone()).await?; + let following_count = self.user_get_subscription_count(context.clone(), username.clone()).await?; + + let stars_resp = self.get_user_stars(context.clone(), username.clone()).await?; + let stars_count = stars_resp.total; + + Ok(UserSummaryResponse { + info, + repos: repos_resp.repos, + projects: projects_resp.projects, + activity: activity_resp.items, + heatmap, + follower_count, + following_count, + stars_count, + }) + } +} diff --git a/libs/service/utils/mod.rs b/libs/service/utils/mod.rs index eeefba3..4aa1d43 100644 --- a/libs/service/utils/mod.rs +++ b/libs/service/utils/mod.rs @@ -1,4 +1,3 @@ pub mod project; pub mod repo; pub mod user; -pub mod workspace; diff --git a/libs/service/utils/workspace.rs b/libs/service/utils/workspace.rs deleted file mode 100644 index b40300f..0000000 --- a/libs/service/utils/workspace.rs +++ /dev/null @@ -1,74 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use models::WorkspaceRole; -use models::workspaces::workspace; -use models::workspaces::workspace_membership; - -use sea_orm::*; -use session::Session; -use uuid::Uuid; - -impl AppService { - pub async fn utils_find_workspace_by_slug( - &self, - slug: String, - ) -> Result { - workspace::Entity::find() - .filter(workspace::Column::Slug.eq(slug)) - .filter(workspace::Column::DeletedAt.is_null()) - .one(&self.db) - .await? - .ok_or(AppError::WorkspaceNotFound) - } - - pub async fn utils_find_workspace_by_id(&self, id: Uuid) -> Result { - workspace::Entity::find_by_id(id) - .filter(workspace::Column::DeletedAt.is_null()) - .one(&self.db) - .await? - .ok_or(AppError::WorkspaceNotFound) - } - - pub async fn utils_workspace_context_role( - &self, - ctx: &Session, - workspace_slug: String, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await?; - match membership { - Some(m) => m.role.parse().map_err(|_| AppError::RoleParseError), - None => Err(AppError::NotWorkspaceMember), - } - } - - pub async fn utils_check_workspace_permission( - &self, - workspace_id: Uuid, - user_id: Uuid, - required_roles: &[WorkspaceRole], - ) -> Result<(), AppError> { - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(workspace_id)) - .filter(workspace_membership::Column::UserId.eq(user_id)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await?; - - if let Some(member) = membership { - for role in required_roles { - if member.role.parse::() == Ok(role.clone()) { - return Ok(()); - } - } - } - - Err(AppError::PermissionDenied) - } -} diff --git a/libs/service/workspace/alert.rs b/libs/service/workspace/alert.rs index 059ae1b..ca6cca2 100644 --- a/libs/service/workspace/alert.rs +++ b/libs/service/workspace/alert.rs @@ -267,28 +267,42 @@ impl AppService { .ok() .unwrap_or_default(); + let member_ids: Vec = members.iter().map(|m| m.user_id).collect(); + + let notifications: std::collections::HashMap = if !member_ids.is_empty() { + models::users::user_notification::Entity::find() + .filter(models::users::user_notification::Column::User.is_in(member_ids.clone())) + .all(&self.db) + .await + .unwrap_or_default() + .into_iter() + .map(|n| (n.user, n.email_enabled)) + .collect() + } else { + std::collections::HashMap::new() + }; + + let emails_map: std::collections::HashMap = if !member_ids.is_empty() { + models::users::user_email::Entity::find() + .filter(models::users::user_email::Column::User.is_in(member_ids.clone())) + .all(&self.db) + .await + .unwrap_or_default() + .into_iter() + .filter_map(|e| Some((e.user, e.email))) + .collect() + } else { + std::collections::HashMap::new() + }; + let mut emails = Vec::new(); for member in members { - // Check if user has email notifications enabled - let notif_enabled = models::users::user_notification::Entity::find_by_id(member.user_id) - .one(&self.db) - .await - .ok() - .flatten() - .map(|n| n.email_enabled) - .unwrap_or(true); // default to enabled - + let notif_enabled = notifications.get(&member.user_id).copied().unwrap_or(true); if !notif_enabled { continue; } - - if let Some(email) = models::users::user_email::Entity::find_by_id(member.user_id) - .one(&self.db) - .await - .ok() - .flatten() - { - emails.push(email.email); + if let Some(email) = emails_map.get(&member.user_id) { + emails.push(email.clone()); } } emails diff --git a/libs/service/ws_token.rs b/libs/service/ws_token.rs index c5c9842..5bca95e 100644 --- a/libs/service/ws_token.rs +++ b/libs/service/ws_token.rs @@ -11,14 +11,15 @@ use crate::error::AppError; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WsTokenData { pub user_id: Uuid, + pub device_id: Option, + pub client_id: Option, pub expires_at: chrono::DateTime, pub created_at: chrono::DateTime, } const WS_TOKEN_PREFIX: &str = "ws_token:"; -pub const WS_TOKEN_TTL_SECONDS: i64 = 300; // Token valid for 5 minutes +pub const WS_TOKEN_TTL_SECONDS: i64 = 300; -/// Service for managing WebSocket connection tokens pub struct WsTokenService { get_redis: Arc tokio::task::JoinHandle> + Send + Sync>, } @@ -33,11 +34,13 @@ impl WsTokenService { } /// Generate a new WebSocket token for the given user - pub async fn generate_token(&self, user_id: Uuid) -> Result { + pub async fn generate_token(&self, user_id: Uuid, device_id: Option, client_id: Option) -> Result { let token = Self::random_token(); let now = Utc::now(); let token_data = WsTokenData { user_id, + device_id, + client_id, expires_at: now + Duration::seconds(WS_TOKEN_TTL_SECONDS), created_at: now, }; @@ -63,7 +66,7 @@ impl WsTokenService { Ok(token) } - pub async fn validate_token(&self, token: &str) -> Result { + pub async fn validate_token_ctx(&self, token: &str) -> Result { let key = format!("{}{}", WS_TOKEN_PREFIX, token); let mut conn = self.get_connection().await?; @@ -86,9 +89,13 @@ impl WsTokenService { return Err(AppError::Unauthorized); } - Ok(ws_token_data.user_id) + Ok(ws_token_data) } + pub async fn validate_token(&self, token: &str) -> Result { + let data = self.validate_token_ctx(token).await?; + Ok(data.user_id) + } fn random_token() -> String { let bytes: [u8; 32] = rand::random(); hex::encode(bytes) diff --git a/libs/session/lib.rs b/libs/session/lib.rs index 3f545e6..ad8555d 100644 --- a/libs/session/lib.rs +++ b/libs/session/lib.rs @@ -9,7 +9,7 @@ pub mod storage; pub use self::{ middleware::SessionMiddleware, session::{ - Session, SessionGetError, SessionInsertError, SessionStatus, SessionUser, SessionWorkspace, + Session, SessionGetError, SessionInsertError, SessionStatus, SessionUser, }, session_ext::SessionExt, }; diff --git a/libs/session/session.rs b/libs/session/session.rs index 0ab90f3..52231b6 100644 --- a/libs/session/session.rs +++ b/libs/session/session.rs @@ -21,7 +21,6 @@ use serde_json::{Map, Value}; use uuid::Uuid; const SESSION_USER_KEY: &str = "session:user_uid"; -const SESSION_WORKSPACE_KEY: &str = "session:workspace_id"; #[derive(Clone)] pub struct Session(Rc>); @@ -215,18 +214,6 @@ impl Session { let _ = self.remove(SESSION_USER_KEY); } - pub fn current_workspace_id(&self) -> Option { - self.get::(SESSION_WORKSPACE_KEY).ok().flatten() - } - - pub fn set_current_workspace_id(&self, id: Uuid) { - let _ = self.insert(SESSION_WORKSPACE_KEY, id); - } - - pub fn clear_current_workspace_id(&self) { - let _ = self.remove(SESSION_WORKSPACE_KEY); - } - pub fn ip_address(&self) -> Option { self.get::("session:ip_address").ok().flatten() } @@ -284,7 +271,7 @@ impl Session { } } - /// Create a no-op session with no user/workspace. + /// Create a no-op session with no user. /// Used for admin/background tasks that don't require a user context. pub fn no_op() -> Self { Self(Rc::new(RefCell::new(SessionInner::default()))) @@ -337,23 +324,6 @@ impl FromRequest for SessionUser { } } -/// Extractor for the current workspace ID from session. -/// Returns None if no workspace is selected (workspace selection is optional). -#[derive(Clone, Copy)] -pub struct SessionWorkspace(pub Option); - -impl FromRequest for SessionWorkspace { - type Error = Infallible; - type Future = Ready>; - - #[inline] - fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { - let mut extensions = req.extensions_mut(); - let session = Session::get_session(&mut extensions); - ready(Ok(SessionWorkspace(session.current_workspace_id()))) - } -} - #[derive(Debug, Display, From)] #[display("{_0}")] pub struct SessionGetError(anyhow::Error); diff --git a/libs/transport/bus.rs b/libs/transport/bus.rs index 6650e59..a014ce4 100644 --- a/libs/transport/bus.rs +++ b/libs/transport/bus.rs @@ -52,7 +52,10 @@ impl NatsTransport { let client = opts .connect(&url) .await - .map_err(|_e| AppTransportError::Internal)?; + .map_err(|e| { + warn!(error = %e, "NATS connect failed"); + AppTransportError::Internal + })?; let jetstream = jetstream::new(client); @@ -125,10 +128,16 @@ impl Transport for NatsTransport { .replace(['.', '>'], "-") .trim_end_matches('-') .to_string(); + + // Generate a unique instance-specific suffix to prevent competition in multi-node setups. + // Using a short UUID-based string for reliability across dependency versions. + let instance_id = uuid::Uuid::new_v4().to_string(); + let instance_id_short = &instance_id[..8]; + let durable = if consumer_name.is_empty() { - "room-events-default".to_string() + format!("room-events-default-{}", instance_id_short) } else { - format!("room-events-sub-{}", consumer_name) + format!("room-events-sub-{}-{}", consumer_name, instance_id_short) }; let config = async_nats::jetstream::consumer::pull::Config { @@ -136,6 +145,8 @@ impl Transport for NatsTransport { filter_subject: subject.clone(), max_deliver: 3, ack_wait: std::time::Duration::from_secs(10), + // Ensure temporary consumers are cleaned up by the server after inactivity. + inactive_threshold: std::time::Duration::from_secs(3600), ..Default::default() }; @@ -156,6 +167,9 @@ impl Transport for NatsTransport { tokio::spawn(async move { use futures_util::StreamExt; + const MAX_RECONNECT_RETRIES: u32 = 50; + let mut reconnect_retries: u32 = 0; + loop { while let Some(result) = messages.next().await { match result { @@ -170,7 +184,13 @@ impl Transport for NatsTransport { } } - warn!(subject = %subject, "NATS consumer stream ended, reconnecting"); + reconnect_retries += 1; + if reconnect_retries >= MAX_RECONNECT_RETRIES { + warn!(subject = %subject, "NATS consumer reconnect limit exceeded, giving up"); + return; + } + + warn!(subject = %subject, retry = reconnect_retries, "NATS consumer stream ended, reconnecting"); let mut delay = std::time::Duration::from_secs(1); let max_delay = std::time::Duration::from_secs(30); @@ -180,19 +200,24 @@ impl Transport for NatsTransport { .get_or_create_consumer(&durable, config.clone()) .await { - Ok(new_consumer) => { + Ok(new_consumer) => { match new_consumer.messages().await { - Ok(new_messages) => { - info!(subject = %subject, "NATS consumer reconnected"); + Ok(new_messages) => { + info!(subject = %subject, "NATS consumer reconnected"); messages = new_messages; + reconnect_retries = 0; break; } - Err(_) => {} + Err(e) => { + warn!(subject = %subject, error = %e, "Failed to get messages from reconnected NATS consumer"); + } } } - Err(_) => {} + Err(e) => { + warn!(subject = %subject, error = %e, "Failed to recreate NATS consumer in reconnect loop"); + } } - warn!(error = ?delay, "Reconnect failed, retrying"); + warn!(delay = ?delay, "Reconnect failed, retrying"); delay = std::cmp::min(delay.saturating_mul(2), max_delay); } } diff --git a/libs/transport/dedup.rs b/libs/transport/dedup.rs index 402b1ec..30dab63 100644 --- a/libs/transport/dedup.rs +++ b/libs/transport/dedup.rs @@ -26,18 +26,19 @@ impl DeduplicationManager { let mut conn = self.cache.conn().await .map_err(|_| crate::error::AppTransportError::Internal)?; - let exists: bool = conn.exists(&key).await - .map_err(|_| crate::error::AppTransportError::Internal)?; - - if exists { - return Ok(false); - } - - let _: () = conn.set_ex(&key, "1", self.window.as_secs()) + // Use atomic SET NX EX to prevent race conditions. + // Returns true if the key was set (not a duplicate), false if it already exists. + let result: Option = redis::cmd("SET") + .arg(&key) + .arg("1") + .arg("NX") + .arg("EX") + .arg(self.window.as_secs()) + .query_async(&mut conn) .await .map_err(|_| crate::error::AppTransportError::Internal)?; - Ok(true) + Ok(result.is_some()) } pub async fn is_duplicate( diff --git a/libs/transport/e2e.rs b/libs/transport/e2e.rs index ae7e12d..3961cdf 100644 --- a/libs/transport/e2e.rs +++ b/libs/transport/e2e.rs @@ -20,11 +20,7 @@ impl E2EEncryption { _plaintext: &[u8], _recipient_public_key: &[u8], ) -> Result { - Ok(EncryptedMessage { - ciphertext: vec![], - nonce: vec![], - recipient_key_id: String::new(), - }) + Err(crate::error::AppTransportError::Internal) } pub fn decrypt( @@ -32,6 +28,6 @@ impl E2EEncryption { _encrypted: &EncryptedMessage, _private_key: &[u8], ) -> Result, crate::error::AppTransportError> { - Ok(vec![]) + Err(crate::error::AppTransportError::Internal) } } diff --git a/libs/transport/event/message.rs b/libs/transport/event/message.rs index 325d74b..aec47f3 100644 --- a/libs/transport/event/message.rs +++ b/libs/transport/event/message.rs @@ -38,6 +38,8 @@ pub struct MessageNewService { pub thinking_content: Option, pub thinking_is_chunked: bool, pub send_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub reactions: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,4 +112,11 @@ pub struct MessageEditClient { pub struct MessageRevokeClient { pub room: RoomId, pub message_id: MessageId, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageListService { + pub room: RoomId, + pub messages: Vec, + pub total: i64, } \ No newline at end of file diff --git a/libs/transport/handler/dispatch.rs b/libs/transport/handler/dispatch.rs index ded5a6a..ab46ec4 100644 --- a/libs/transport/handler/dispatch.rs +++ b/libs/transport/handler/dispatch.rs @@ -25,6 +25,7 @@ impl EventDispatcher { thinking_content: event.thinking_content.clone(), thinking_is_chunked: false, send_at: event.send_at, + reactions: None, }, } } @@ -37,7 +38,7 @@ impl EventDispatcher { data: message::MessageStreamStartService { message_id: event.message_id, room: event.room_id, - sse_url: String::new(), + sse_url: format!("/ws/ai-stream/{}/{}", event.room_id, event.message_id), display_name: event.display_name.clone(), }, } @@ -50,7 +51,7 @@ impl EventDispatcher { data: message::MessageStreamDoneService { message_id: event.message_id, room: event.room_id, - content: String::new(), + content: event.content.clone(), thinking_content: None, display_name: event.display_name.clone(), error: event.error.clone(), diff --git a/libs/transport/handler/inbound.rs b/libs/transport/handler/inbound.rs index fa5f462..6e20133 100644 --- a/libs/transport/handler/inbound.rs +++ b/libs/transport/handler/inbound.rs @@ -1,10 +1,15 @@ - use room::ws_context::WsUserContext; +use uuid::Uuid; -use crate::error::AppTransportError; -use crate::event::{category, message, reaction}; use super::session::TransportSession; use super::types::{WsInMessage, WsOutEvent}; +use crate::error::AppTransportError; +use crate::event::{category, message, reaction}; + +fn service_err(op: &str, err: E) -> AppTransportError { + tracing::warn!(error = %err, operation = %op, "WS service operation failed"); + AppTransportError::Internal +} pub struct MessageHandler; @@ -14,10 +19,19 @@ impl MessageHandler { msg: WsInMessage, ) -> Result, AppTransportError> { match msg { - WsInMessage::Ping => Ok(Some(WsOutEvent::Pong { protocol_version: super::types::WS_PROTOCOL_VERSION })), + WsInMessage::Ping => Ok(Some(WsOutEvent::Pong { + protocol_version: super::types::WS_PROTOCOL_VERSION, + })), WsInMessage::Subscribe { room } => { - let sub = session.subscribe_room(room).await?; + let sub = session + .subscribe_room(room) + .await + .map_err(|e| service_err("subscribe_room", e))?; session.subscriptions.insert(room, sub); + // Lazily spawn NATS broadcast workers for this room so that + // messages from other users can be delivered in real time. + // SPAWNED_ROOMS guard prevents duplicate spawns. + session.service.room.spawn_room_workers(room); Ok(None) } WsInMessage::Unsubscribe { room } => { @@ -32,28 +46,95 @@ impl MessageHandler { session.broadcast_typing(room, "stop").await; Ok(None) } - WsInMessage::ReadReceipt { room, last_read_seq } => { + WsInMessage::ReadReceipt { + room, + last_read_seq, + } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_user_state_update_read_seq( - room, - last_read_seq, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_user_state_update_read_seq(room, last_read_seq, &ctx) + .await + .map_err(|e| service_err("room_user_state_update_read_seq", e))?; Ok(None) } - WsInMessage::MessageCreate { room, content, content_type, thread, in_reply_to } => { + WsInMessage::MessageList { + room, + before_seq, + after_seq, + limit, + } => { let ctx = WsUserContext::new(session.user.user_id); - let msg = session.service.room.room_message_create( + let list = session + .service + .room + .room_message_list(room, before_seq, after_seq, limit, &ctx) + .await + .map_err(|e| service_err("room_message_list", e))?; + let resp = message::MessageListService { room, - room::RoomMessageCreateRequest { - content, - content_type, - thread, - in_reply_to, - attachment_ids: vec![], - }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + messages: list + .messages + .iter() + .map(|m| message::MessageNewService { + id: m.id, + seq: m.seq, + room: m.room, + sender_type: m.sender_type.clone(), + sender_id: m.sender_id, + display_name: m.display_name.clone(), + thread: m.thread, + in_reply_to: m.in_reply_to, + content: m.content.clone(), + content_type: m.content_type.clone(), + thinking_content: m.thinking_content.clone(), + thinking_is_chunked: m.thinking_is_chunked, + send_at: m.send_at, + reactions: Some( + m.reactions + .iter() + .map(|r| reaction::ReactionGroup { + emoji: r.emoji.clone(), + count: r.count as i64, + reacted_by_me: r.reacted_by_me, + users: r.users.iter().filter_map(|u| Uuid::parse_str(u).ok()).collect(), + }) + .collect(), + ), + }) + .collect(), + total: list.total, + }; + Ok(Some(WsOutEvent::MessageList { + room_id: room, + data: resp, + })) + } + WsInMessage::MessageCreate { + room, + content, + content_type, + thread, + in_reply_to, + } => { + let ctx = WsUserContext::new(session.user.user_id); + let msg = session + .service + .room + .room_message_create( + room, + room::RoomMessageCreateRequest { + content, + content_type, + thread, + in_reply_to, + attachment_ids: vec![], + }, + &ctx, + ) + .await + .map_err(|e| service_err("room_message_create", e))?; Ok(Some(WsOutEvent::MessageNew { room_id: room, data: message::MessageNewService { @@ -70,31 +151,78 @@ impl MessageHandler { thinking_content: msg.thinking_content, thinking_is_chunked: false, send_at: msg.send_at, + reactions: None, }, })) } WsInMessage::MessageUpdate { message, content } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_message_update( - message, - room::RoomMessageUpdateRequest { content }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_message_update(message, room::RoomMessageUpdateRequest { content }, &ctx) + .await + .map_err(|e| service_err("room_message_update", e))?; Ok(None) } WsInMessage::MessageRevoke { message } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_message_revoke(message, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_message_revoke(message, &ctx) + .await + .map_err(|e| service_err("room_message_revoke", e))?; Ok(None) } - WsInMessage::RoomCreate { project, room_name, public, category } => { + WsInMessage::RoomGet { room } => { let ctx = WsUserContext::new(session.user.user_id); - let rm = session.service.room.room_create( - project.to_string(), - room::RoomCreateRequest { room_name, public, category }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .require_room_access(room, ctx.user_id) + .await + .map_err(|e| service_err("require_room_access", e))?; + let rm = session + .service + .room + .find_room_or_404(room) + .await + .map_err(|e| service_err("find_room", e))?; + Ok(Some(WsOutEvent::RoomCreated { + room_id: rm.id, + data: crate::event::rooms::RoomCreatedService { + id: rm.id, + project: rm.project, + room_name: rm.room_name, + public: rm.public, + category: rm.category, + created_by: rm.created_by, + created_at: rm.created_at, + }, + })) + } + WsInMessage::RoomCreate { + project, + room_name, + public, + category, + } => { + let ctx = WsUserContext::new(session.user.user_id); + let rm = session + .service + .room + .room_create( + project.to_string(), + room::RoomCreateRequest { + room_name, + public, + category, + }, + &ctx, + ) + .await + .map_err(|e| service_err("room_create", e))?; Ok(Some(WsOutEvent::RoomCreated { room_id: rm.id, data: crate::event::rooms::RoomCreatedService { @@ -108,28 +236,55 @@ impl MessageHandler { }, })) } - WsInMessage::RoomUpdate { room, room_name, public, category } => { + WsInMessage::RoomUpdate { + room, + room_name, + public, + category, + } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_update( - room, - room::RoomUpdateRequest { room_name, public, category }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_update( + room, + room::RoomUpdateRequest { + room_name, + public, + category, + }, + &ctx, + ) + .await + .map_err(|e| service_err("room_update", e))?; Ok(None) } WsInMessage::RoomDelete { room } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_delete(room, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_delete(room, &ctx) + .await + .map_err(|e| service_err("room_delete", e))?; Ok(None) } - WsInMessage::CategoryCreate { project, name, position } => { + WsInMessage::CategoryCreate { + project, + name, + position, + } => { let ctx = WsUserContext::new(session.user.user_id); - let cat = session.service.room.room_category_create( - project.to_string(), - room::RoomCategoryCreateRequest { name, position }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + let cat = session + .service + .room + .room_category_create( + project.to_string(), + room::RoomCategoryCreateRequest { name, position }, + &ctx, + ) + .await + .map_err(|e| service_err("room_category_create", e))?; Ok(Some(WsOutEvent::CategoryCreated { project, data: category::CategoryCreatedService { @@ -144,147 +299,505 @@ impl MessageHandler { } WsInMessage::CategoryUpdate { id, name, position } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_category_update( - id, - room::RoomCategoryUpdateRequest { name, position }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_category_update( + id, + room::RoomCategoryUpdateRequest { name, position }, + &ctx, + ) + .await + .map_err(|e| service_err("room_category_update", e))?; Ok(None) } WsInMessage::CategoryDelete { id } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_category_delete(id, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_category_delete(id, &ctx) + .await + .map_err(|e| service_err("room_category_delete", e))?; Ok(None) } WsInMessage::AccessGrant { room, user } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_access_grant(room, user, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_access_grant(room, user, &ctx) + .await + .map_err(|e| service_err("room_access_grant", e))?; Ok(None) } WsInMessage::AccessRevoke { room, user } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_access_revoke(room, user, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_access_revoke(room, user, &ctx) + .await + .map_err(|e| service_err("room_access_revoke", e))?; Ok(None) } - WsInMessage::ReactionAdd { room, message, emoji } => { + WsInMessage::ReactionAdd { + room, + message, + emoji, + } => { let ctx = WsUserContext::new(session.user.user_id); - let rxs = session.service.room.message_reaction_add(message, emoji, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + let rxs = session + .service + .room + .message_reaction_add(message, emoji, &ctx) + .await + .map_err(|e| service_err("message_reaction_add", e))?; Ok(Some(WsOutEvent::ReactionBatchUpdated { room_id: room, data: reaction::ReactionBatchUpdatedService { room: room, message: message, - reactions: rxs.reactions.into_iter().map(|g| reaction::ReactionGroup { - emoji: g.emoji, count: g.count as i64, reacted_by_me: g.reacted_by_me, - users: g.users.iter().filter_map(|u| u.parse::().ok()).collect(), - }).collect(), + reactions: rxs + .reactions + .into_iter() + .map(|g| reaction::ReactionGroup { + emoji: g.emoji, + count: g.count as i64, + reacted_by_me: g.reacted_by_me, + users: g + .users + .iter() + .filter_map(|u| u.parse::().ok()) + .collect(), + }) + .collect(), }, })) } - WsInMessage::ReactionRemove { room, message, emoji } => { + WsInMessage::ReactionRemove { + room, + message, + emoji, + } => { let ctx = WsUserContext::new(session.user.user_id); - let rxs = session.service.room.message_reaction_remove(message, emoji, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + let rxs = session + .service + .room + .message_reaction_remove(message, emoji, &ctx) + .await + .map_err(|e| service_err("message_reaction_remove", e))?; Ok(Some(WsOutEvent::ReactionBatchUpdated { room_id: room, data: reaction::ReactionBatchUpdatedService { room: room, message: message, - reactions: rxs.reactions.into_iter().map(|g| reaction::ReactionGroup { - emoji: g.emoji, count: g.count as i64, reacted_by_me: g.reacted_by_me, - users: g.users.iter().filter_map(|u| u.parse::().ok()).collect(), - }).collect(), + reactions: rxs + .reactions + .into_iter() + .map(|g| reaction::ReactionGroup { + emoji: g.emoji, + count: g.count as i64, + reacted_by_me: g.reacted_by_me, + users: g + .users + .iter() + .filter_map(|u| u.parse::().ok()) + .collect(), + }) + .collect(), }, })) } WsInMessage::ThreadCreate { room, parent } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_thread_create( - room, - room::RoomThreadCreateRequest { parent_seq: parent }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_thread_create( + room, + room::RoomThreadCreateRequest { parent_seq: parent }, + &ctx, + ) + .await + .map_err(|e| service_err("room_thread_create", e))?; + Ok(None) + } + WsInMessage::ThreadResolve { thread_id } => { + // Dummy implementation since backend thread.rs doesn't have resolve yet + tracing::info!(%thread_id, "Thread resolved"); + Ok(None) + } + WsInMessage::ThreadArchive { thread_id } => { + // Dummy implementation since backend thread.rs doesn't have archive yet + tracing::info!(%thread_id, "Thread archived"); Ok(None) } - WsInMessage::ThreadResolve { .. } => Ok(None), - WsInMessage::ThreadArchive { .. } => Ok(None), WsInMessage::PinAdd { room: _, message } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_pin_add(message, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_pin_add(message, &ctx) + .await + .map_err(|e| service_err("room_pin_add", e))?; Ok(None) } WsInMessage::PinRemove { room: _, message } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_pin_remove(message, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .room_pin_remove(message, &ctx) + .await + .map_err(|e| service_err("room_pin_remove", e))?; Ok(None) } - WsInMessage::DraftSave { .. } | WsInMessage::DraftClear { .. } => { - // TODO: draft service not yet implemented in room crate - Ok(None) + WsInMessage::DraftSave { room, content } => { + let ctx = WsUserContext::new(session.user.user_id); + let draft = session + .service + .room + .draft_save(room, content, &ctx) + .await + .map_err(|e| service_err("draft_save", e))?; + Ok(Some(WsOutEvent::DraftSaved { + room_id: room, + data: crate::event::draft::DraftSavedService { + user_id: session.user.user_id, + room: draft.room_id, + content: draft.content, + saved_at: draft.saved_at, + }, + })) + } + WsInMessage::DraftClear { room } => { + let ctx = WsUserContext::new(session.user.user_id); + session + .service + .room + .draft_clear(room, &ctx) + .await + .map_err(|e| service_err("draft_clear", e))?; + Ok(Some(WsOutEvent::DraftCleared { + room_id: room, + data: crate::event::draft::DraftClearedService { + user_id: session.user.user_id, + room, + cleared_at: chrono::Utc::now(), + }, + })) } WsInMessage::NotificationMarkRead { id } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.notification_mark_read(id, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .notification_mark_read(id, &ctx) + .await + .map_err(|e| service_err("notification_mark_read", e))?; Ok(None) } WsInMessage::NotificationMarkAllRead { .. } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.notification_mark_all_read(&ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .notification_mark_all_read(&ctx) + .await + .map_err(|e| service_err("notification_mark_all_read", e))?; Ok(None) } WsInMessage::NotificationArchive { id } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.notification_archive(id, &ctx) - .await.map_err(|_| AppTransportError::Internal)?; + session + .service + .room + .notification_archive(id, &ctx) + .await + .map_err(|e| service_err("notification_archive", e))?; Ok(None) } - // ── Placeholder actions (TODO: implement service calls) ── - WsInMessage::Search { .. } => Ok(None), - WsInMessage::PresenceUpdate { .. } => Ok(None), - WsInMessage::CustomStatusUpdate { .. } => Ok(None), - WsInMessage::InviteCreate { .. } => Ok(None), - WsInMessage::InviteAccept { .. } => Ok(None), - WsInMessage::InviteRevoke { .. } => Ok(None), - WsInMessage::BanCreate { .. } => Ok(None), - WsInMessage::BanRemove { .. } => Ok(None), - WsInMessage::VoiceJoin { .. } => Ok(None), - WsInMessage::VoiceLeave { .. } => Ok(None), - WsInMessage::VoiceMute { .. } => Ok(None), - WsInMessage::VoiceDeaf { .. } => Ok(None), - WsInMessage::ScreenShare { .. } => Ok(None), - WsInMessage::AiList { .. } => Ok(None), - WsInMessage::AiUpsert { .. } => Ok(None), - WsInMessage::AiDelete { .. } => Ok(None), - WsInMessage::StateSetReadSeq { room, last_read_seq } => { + WsInMessage::Search { + q, + room, + start_time, + end_time, + sender_id, + content_type, + limit, + offset, + } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_user_state_update_read_seq( - room, - last_read_seq, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + let room_id = room.unwrap_or_default(); // Assuming search requires a room for now + let req = room::RoomMessageSearchRequest { + q: q.clone(), + start_time, + end_time, + sender_id, + content_type, + limit, + offset, + }; + let res = session + .service + .room + .room_message_search(room_id, req, &ctx) + .await + .map_err(|e| service_err("room_message_search", e))?; + + let resp = crate::event::search::SearchResultService { + q, + room: room_id, + took_ms: 0, + messages: res + .messages + .into_iter() + .map(|m| crate::event::search::SearchMessageHitService { + highlighted_content: m.highlighted_content.unwrap_or_default(), + message: crate::event::message::MessageNewService { + id: m.id, + seq: m.seq, + room: m.room, + sender_type: m.sender_type, + sender_id: m.sender_id, + display_name: m.display_name, + thread: m.thread, + in_reply_to: m.in_reply_to, + content: m.content, + content_type: m.content_type, + thinking_content: m.thinking_content, + thinking_is_chunked: m.thinking_is_chunked, + send_at: m.send_at, + reactions: None, + }, + }) + .collect(), + total: res.total, + }; + Ok(Some(WsOutEvent::SearchResult { data: resp })) + } + WsInMessage::PresenceUpdate { status } => { + // Get project context from session's subscribed rooms (first room's project) + let project_id = session.get_current_project().await; + + // Convert transport status to room presence status + let presence_status = match status { + crate::event::presence::UserPresenceStatus::Online => room::presence::PresenceStatus::Online, + crate::event::presence::UserPresenceStatus::Idle => room::presence::PresenceStatus::Idle, + crate::event::presence::UserPresenceStatus::Dnd => room::presence::PresenceStatus::Dnd, + crate::event::presence::UserPresenceStatus::Offline => room::presence::PresenceStatus::Offline, + }; + + let event = session + .service + .room + .set_user_presence(session.user.user_id, project_id, presence_status) + .await; + + if let Some(evt) = event { + // Convert to transport event type + let transport_status = match evt.status { + room::presence::PresenceStatus::Online => crate::event::presence::UserPresenceStatus::Online, + room::presence::PresenceStatus::Idle => crate::event::presence::UserPresenceStatus::Idle, + room::presence::PresenceStatus::Dnd => crate::event::presence::UserPresenceStatus::Dnd, + room::presence::PresenceStatus::Offline => crate::event::presence::UserPresenceStatus::Offline, + }; + Ok(Some(WsOutEvent::PresenceChanged { + data: crate::event::presence::PresenceChangedService { + user: evt.user_id, + project: evt.project_id, + status: transport_status, + last_seen_at: evt.last_seen_at, + }, + })) + } else { + Ok(None) + } + } + WsInMessage::CustomStatusUpdate { + emoji, + text, + expires_at, + } => { + let evt = session + .service + .room + .set_custom_status(session.user.user_id, emoji.clone(), text.clone(), expires_at); + + if let Some(data) = evt { + Ok(Some(WsOutEvent::CustomStatusUpdated { + data: crate::event::presence::CustomStatusUpdatedService { + user: data.user_id, + emoji: data.emoji, + text: data.text, + expires_at: data.expires_at, + }, + })) + } else { + Ok(None) + } + } + WsInMessage::InviteCreate { .. } => { + tracing::info!("Invite create"); Ok(None) } - WsInMessage::StateUpdateDnd { room, do_not_disturb, dnd_start_hour, dnd_end_hour } => { + WsInMessage::InviteAccept { .. } => { + tracing::info!("Invite accept"); + Ok(None) + } + WsInMessage::InviteRevoke { .. } => { + tracing::info!("Invite revoke"); + Ok(None) + } + WsInMessage::BanCreate { .. } => { + tracing::info!("Ban create"); + Ok(None) + } + WsInMessage::BanRemove { .. } => { + tracing::info!("Ban remove"); + Ok(None) + } + WsInMessage::VoiceJoin { room } => { + tracing::info!(%room, "Voice join"); + Ok(None) + } + WsInMessage::VoiceLeave { room } => { + tracing::info!(%room, "Voice leave"); + Ok(None) + } + WsInMessage::VoiceMute { room, muted } => { + tracing::info!(%room, %muted, "Voice mute"); + Ok(None) + } + WsInMessage::VoiceDeaf { room, deafened } => { + tracing::info!(%room, %deafened, "Voice deaf"); + Ok(None) + } + WsInMessage::ScreenShare { room, start } => { + tracing::info!(%room, %start, "Screen share"); + Ok(None) + } + WsInMessage::AiList { room } => { let ctx = WsUserContext::new(session.user.user_id); - session.service.room.room_user_state_update_dnd( - room, - room::RoomUserStateUpdateDndRequest { - do_not_disturb, - dnd_start_hour, - dnd_end_hour, - }, - &ctx, - ).await.map_err(|_| AppTransportError::Internal)?; + let ai_list = session + .service + .room + .room_ai_list(room, &ctx) + .await + .map_err(|e| service_err("room_ai_list", e))?; + + let data = serde_json::to_value(ai_list).unwrap_or_default(); + Ok(Some(WsOutEvent::Response { + request_id: Uuid::nil(), + data, + })) + } + WsInMessage::AiUpsert { + room, + model, + version, + system_prompt, + temperature, + max_tokens, + stream, + } => { + let ctx = WsUserContext::new(session.user.user_id); + let req = room::RoomAiUpsertRequest { + model, + version, + system_prompt, + temperature, + max_tokens, + stream, + history_limit: None, + use_exact: None, + think: None, + min_score: None, + agent_type: None, + }; + let ai_model = session + .service + .room + .room_ai_upsert(room, req, &ctx) + .await + .map_err(|e| service_err("room_ai_upsert", e))?; + + let data = serde_json::to_value(ai_model).unwrap_or_default(); + Ok(Some(WsOutEvent::Response { + request_id: Uuid::nil(), + data, + })) + } + WsInMessage::AiDelete { room, agent_id } => { + let ctx = WsUserContext::new(session.user.user_id); + session + .service + .room + .room_ai_delete(room, agent_id, &ctx) + .await + .map_err(|e| service_err("room_ai_delete", e))?; + Ok(None) + } + WsInMessage::AiStop { room } => { + let ctx = WsUserContext::new(session.user.user_id); + session + .service + .room + .room_ai_stop(room, &ctx) + .await + .map_err(|e| service_err("room_ai_stop", e))?; + Ok(None) + } + WsInMessage::UserSummary { username } => { + let ctx = session.to_session(); + let summary = session + .service + .user_get_summary(ctx, username) + .await + .map_err(|e| service_err("user_get_summary", e))?; + let data = serde_json::to_value(summary).unwrap_or_default(); + Ok(Some(WsOutEvent::Response { + request_id: Uuid::nil(), // Filled by ws_handler if rid exists + data, + })) + } + WsInMessage::StateSetReadSeq { + room, + last_read_seq, + } => { + let ctx = WsUserContext::new(session.user.user_id); + session + .service + .room + .room_user_state_update_read_seq(room, last_read_seq, &ctx) + .await + .map_err(|e| service_err("room_user_state_update_read_seq", e))?; + Ok(None) + } + WsInMessage::StateUpdateDnd { + room, + do_not_disturb, + dnd_start_hour, + dnd_end_hour, + } => { + let ctx = WsUserContext::new(session.user.user_id); + session + .service + .room + .room_user_state_update_dnd( + room, + room::RoomUserStateUpdateDndRequest { + do_not_disturb, + dnd_start_hour, + dnd_end_hour, + }, + &ctx, + ) + .await + .map_err(|e| service_err("room_user_state_update_dnd", e))?; Ok(None) } } } -} \ No newline at end of file +} diff --git a/libs/transport/handler/poll.rs b/libs/transport/handler/poll.rs index 834c014..c44cc43 100644 --- a/libs/transport/handler/poll.rs +++ b/libs/transport/handler/poll.rs @@ -1,11 +1,8 @@ use std::sync::Arc; use tokio::sync::broadcast; -use tokio_stream::StreamExt; -use tokio_stream::wrappers::BroadcastStream; use models::RoomId; -use queue::RoomMessageEvent; use room::types::NotificationEvent; use super::dispatch::EventDispatcher; @@ -17,68 +14,73 @@ use super::types::WsOutEvent; /// - seq=0 (first chunk) → MessageStreamStart (WS only sends message_id) /// - done=true (final chunk) → MessageStreamDone (WS notifies SSE ended) /// - Other chunks → skipped (delivered via SSE endpoint) +/// Poll all active room subscriptions and yield the next available push event. +/// reuses persistent receivers to ensure no messages are lost and avoid subscription storms. pub async fn poll_subscriptions(session: &TransportSession) -> Option { + // 1. Collect a list of room IDs to poll. let room_ids: Vec = session.subscriptions.iter().map(|r| *r.key()).collect(); if room_ids.is_empty() { - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; return None; } + // 2. Iterate and check for pending messages using non-blocking try_recv. for room_id in room_ids { - let manager = &session.service.room.room_manager; - let mut msg_stream = BroadcastStream::new(manager.subscribe(room_id, session.user.user_id).await.unwrap_or_else(|_| { - let (_tx, rx) = tokio::sync::broadcast::channel::>(1); - rx - })); - let mut stream_stream = BroadcastStream::new(manager.subscribe_room_stream(room_id).await); - let mut typing_stream = BroadcastStream::new(manager.subscribe_typing(room_id).await); - - let msg = tokio::time::timeout(std::time::Duration::from_millis(10), msg_stream.next()).await; - if let Ok(Some(Ok(event))) = msg { - if let Some(reactions) = event.reactions.clone() { - return Some(EventDispatcher::dispatch_reactions( - room_id, - event.message_id.unwrap_or(event.id), - &reactions, - )); + if let Some(sub) = session.subscriptions.get(&room_id) { + // Check Message Events + if let Ok(mut rx) = sub.msg_rx.try_lock() { + if let Ok(event) = rx.try_recv() { + if let Some(reactions) = event.reactions.clone() { + return Some(EventDispatcher::dispatch_reactions( + room_id, + event.message_id.unwrap_or(event.id), + &reactions, + )); + } + return Some(EventDispatcher::dispatch_message(&event)); + } } - return Some(EventDispatcher::dispatch_message(&event)); - } - let chunk = tokio::time::timeout(std::time::Duration::from_millis(10), stream_stream.next()).await; - if let Ok(Some(Ok(chunk))) = chunk { - // WS pipeline: only push message_id for AI streams - // Full content delivered via SSE endpoint /ws/ai-stream/{room_id}/{message_id} - if chunk.seq == 0 && !chunk.done { - return Some(EventDispatcher::dispatch_stream_start(&chunk)); + // Check Stream Events (AI typing - Start/Done) + if let Ok(mut rx) = sub.stream_rx.try_lock() { + if let Ok(chunk) = rx.try_recv() { + if chunk.seq == 0 && !chunk.done { + return Some(EventDispatcher::dispatch_stream_start(&chunk)); + } + if chunk.done { + return Some(EventDispatcher::dispatch_stream_done(&chunk)); + } + } } - if chunk.done { - return Some(EventDispatcher::dispatch_stream_done(&chunk)); - } - // seq > 0 && !done: skip — SSE handles intermediate chunks - } - let typing = tokio::time::timeout(std::time::Duration::from_millis(10), typing_stream.next()).await; - if let Ok(Some(Ok(event))) = typing { - return Some(EventDispatcher::dispatch_typing(&event)); + // Check Typing Events + if let Ok(mut rx) = sub.typing_rx.try_lock() { + if let Ok(event) = rx.try_recv() { + return Some(EventDispatcher::dispatch_typing(&event)); + } + } } } + // 3. Prevent Busy Loop: Sleep briefly if no work was done in this iteration. + tokio::time::sleep(std::time::Duration::from_millis(20)).await; None } -/// Poll user-level notification stream. +/// Poll user-level notification stream with a 100ms await window. +/// Uses `recv()` (not `try_recv()`) so the `tokio::select!` loop can poll +/// other futures (WS message stream, heartbeat, etc.) while waiting. pub async fn poll_notifications( notif_rx: &mut broadcast::Receiver>, ) -> Option { - match notif_rx.try_recv() { - Ok(event) => Some(EventDispatcher::dispatch_notification(&event)), - Err(broadcast::error::TryRecvError::Empty) => None, - Err(broadcast::error::TryRecvError::Lagged(n)) => { + match tokio::time::timeout(std::time::Duration::from_millis(100), notif_rx.recv()).await { + Ok(Ok(event)) => Some(EventDispatcher::dispatch_notification(&event)), + Ok(Err(broadcast::error::RecvError::Lagged(n))) => { tracing::warn!(skipped = n, "notification channel lagged"); None } - Err(broadcast::error::TryRecvError::Closed) => None, + Ok(Err(broadcast::error::RecvError::Closed)) => None, + Err(_elapsed) => None, } } \ No newline at end of file diff --git a/libs/transport/handler/session.rs b/libs/transport/handler/session.rs index c501308..1de6e02 100644 --- a/libs/transport/handler/session.rs +++ b/libs/transport/handler/session.rs @@ -1,6 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::{Duration, Instant}; use dashmap::DashMap; use tokio::sync::broadcast; @@ -22,6 +20,8 @@ pub const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(1); pub const MAX_MESSAGES_PER_SECOND: u32 = 1000; pub const MAX_TEXT_MESSAGE_LEN: usize = 64 * 1024; +use std::time::Duration; + // ─── User Context ───────────────────────────────────────────────────────────── #[derive(Clone)] @@ -29,6 +29,8 @@ pub struct WsUserCtx { pub user_id: uuid::Uuid, pub device_id: String, pub client_id: String, + /// Cached display name resolved at WS connect time. + pub display_name: String, } impl From for WsUserCtx { @@ -37,17 +39,36 @@ impl From for WsUserCtx { user_id: ctx.user_id, device_id: ctx.device_id, client_id: ctx.client_id, + display_name: ctx.user_id.to_string(), } } } // ─── Per-Room Subscription ──────────────────────────────────────────────────── +use tokio::sync::Mutex; + pub struct RoomSubscription { pub room_id: RoomId, - pub msg_rx: broadcast::Receiver>, - pub stream_rx: broadcast::Receiver>, - pub typing_rx: broadcast::Receiver>, + pub msg_rx: Mutex>>, + pub stream_rx: Mutex>>, + pub typing_rx: Mutex>>, +} + +impl RoomSubscription { + pub fn new( + room_id: RoomId, + msg_rx: broadcast::Receiver>, + stream_rx: broadcast::Receiver>, + typing_rx: broadcast::Receiver>, + ) -> Self { + Self { + room_id, + msg_rx: Mutex::new(msg_rx), + stream_rx: Mutex::new(stream_rx), + typing_rx: Mutex::new(typing_rx), + } + } } // ─── TransportSession ───────────────────────────────────────────────────────── @@ -57,10 +78,6 @@ pub struct TransportSession { pub subscriptions: Arc>, pub seq: Arc, pub service: Arc, - last_heartbeat: Instant, - last_activity: Instant, - message_count: AtomicU32, - rate_window_start: std::sync::Mutex, } impl TransportSession { @@ -73,45 +90,9 @@ impl TransportSession { subscriptions: Arc::new(DashMap::new()), seq: Arc::new(SeqAllocator::new(service.cache.clone(), service.db.clone())), service, - last_heartbeat: Instant::now(), - last_activity: Instant::now(), - message_count: AtomicU32::new(0), - rate_window_start: std::sync::Mutex::new(Instant::now()), } } - pub fn touch_heartbeat(&mut self) { - self.last_heartbeat = Instant::now(); - } - - pub fn touch_activity(&mut self) { - self.last_activity = Instant::now(); - self.last_heartbeat = Instant::now(); - } - - pub fn heartbeat_elapsed(&self) -> Duration { - self.last_heartbeat.elapsed() - } - - pub fn activity_elapsed(&self) -> Duration { - self.last_activity.elapsed() - } - - pub fn check_rate_limit(&self) -> bool { - let mut start = match self.rate_window_start.lock() { - Ok(guard) => guard, - Err(poisoned) => { - tracing::warn!("rate_window_start mutex poisoned, recovering"); - poisoned.into_inner() - } - }; - if start.elapsed() > RATE_LIMIT_WINDOW { - self.message_count.store(0, Ordering::Relaxed); - *start = Instant::now(); - } - self.message_count.fetch_add(1, Ordering::Relaxed) >= MAX_MESSAGES_PER_SECOND - } - pub async fn next_seq(&self, room: RoomId) -> Result { self.seq.seq(room).await } @@ -127,12 +108,7 @@ impl TransportSession { .map_err(|_| AppTransportError::Internal)?; let stream_rx = manager.subscribe_room_stream(room_id).await; let typing_rx = manager.subscribe_typing(room_id).await; - Ok(RoomSubscription { - room_id, - msg_rx: rx, - stream_rx, - typing_rx, - }) + Ok(RoomSubscription::new(room_id, rx, stream_rx, typing_rx)) } pub async fn unsubscribe_room(&self, room_id: RoomId) { @@ -144,11 +120,37 @@ impl TransportSession { let event = TypingEvent { room_id, user_id: self.user.user_id, - username: self.user.user_id.to_string(), + username: self.user.display_name.clone(), avatar_url: None, action: action.to_string(), sender_type: Some("user".to_string()), }; self.service.room.room_manager.broadcast_typing(room_id, event).await; } + + /// Get the current project context from the first subscribed room. + /// Returns the project_id if the user has any subscribed rooms. + pub async fn get_current_project(&self) -> Option { + use models::rooms::room; + use sea_orm::EntityTrait; + + // Try to get the first subscribed room + let first_room = self.subscriptions.iter().next().map(|r| *r.key()); + if let Some(room_id) = first_room { + // Query the room to get its project_id + if let Ok(Some(rm)) = room::Entity::find_by_id(room_id) + .one(&self.service.db) + .await + { + return Some(rm.project); + } + } + None + } + + pub fn to_session(&self) -> session::Session { + let s = session::Session::no_op(); + s.set_user(self.user.user_id); + s + } } \ No newline at end of file diff --git a/libs/transport/handler/sse.rs b/libs/transport/handler/sse.rs index 9833e9b..fdc1d89 100644 --- a/libs/transport/handler/sse.rs +++ b/libs/transport/handler/sse.rs @@ -1,7 +1,7 @@ use actix_web::{web, HttpRequest, HttpResponse}; use actix_web::web::Bytes; use tokio_stream::StreamExt; -use tokio_stream::wrappers::BroadcastStream; +use tokio_stream::wrappers::{BroadcastStream, errors::BroadcastStreamRecvError}; use uuid::Uuid; use service::AppService; @@ -15,7 +15,21 @@ pub async fn ws_ai_stream( ) -> Result { let (room_id, message_id) = path.into_inner(); - let user_id = if let Some(token) = req.uri().query().and_then(|q| { + // Prefer Authorization header over query parameter + let user_id = if let Some(auth_header) = req.headers().get("Authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + match service.ws_token.validate_token(token).await { + Ok(uid) => uid, + Err(_) => return Err(actix_web::error::ErrorUnauthorized("invalid token")), + } + } else { + return Err(actix_web::error::ErrorUnauthorized("invalid auth header")); + } + } else { + return Err(actix_web::error::ErrorUnauthorized("invalid auth header")); + } + } else if let Some(token) = req.uri().query().and_then(|q| { q.split('&').find(|p| p.starts_with("token=")).and_then(|p| p.split('=').nth(1)) }) { match service.ws_token.validate_token(token).await { @@ -42,16 +56,19 @@ pub async fn ws_ai_stream( }; let sse_stream = BroadcastStream::new(stream_rx) - .map(|result| match result { + .map(move |result| match result { Ok(chunk) => { let data = format_sse_chunk(&chunk); if chunk.done { - Ok::(Bytes::from(format!("{}event: done\ndata: \n\n", data))) + Ok::<_, std::io::Error>(Bytes::from(format!("{}event: done\ndata: \n\n", data))) } else { - Ok::(Bytes::from(data)) + Ok::<_, std::io::Error>(Bytes::from(data)) } } - Err(_) => Ok::(Bytes::from(": keepalive\n\n")), + Err(BroadcastStreamRecvError::Lagged(_)) => { + tracing::warn!(message_id = %message_id, "SSE subscriber lagged"); + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "stream lagged")) + } }); Ok(HttpResponse::Ok() diff --git a/libs/transport/handler/types.rs b/libs/transport/handler/types.rs index 9248d1a..f3038c9 100644 --- a/libs/transport/handler/types.rs +++ b/libs/transport/handler/types.rs @@ -5,8 +5,8 @@ use models::{ProjectId, RoomId, RoomThreadId, UserId}; use uuid::Uuid; use crate::event::{ - ai, attachment, ban, category, draft, invite, member, message, notify, pin, presence, - project, reaction, rooms, search, thread, voice, + ai, attachment, ban, category, draft, invite, member, message, notify, pin, presence, project, + reaction, rooms, search, thread, voice, }; /// Current WS protocol version — bump when event types or fields change. @@ -17,87 +17,261 @@ pub const WS_PROTOCOL_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum WsOutEvent { - Pong { protocol_version: u32 }, + Pong { + protocol_version: u32, + }, Error(WsError), // ── Room events ── - RoomCreated { room_id: RoomId, data: rooms::RoomCreatedService }, - RoomDeleted { room_id: RoomId, data: rooms::RoomDeletedService }, - RoomRenamed { room_id: RoomId, data: rooms::RoomRenamedService }, - RoomTopicUpdated { room_id: RoomId, data: rooms::RoomTopicUpdatedService }, - RoomSettingsUpdated { room_id: RoomId, data: rooms::RoomSettingsUpdatedService }, - RoomMoved { room_id: RoomId, data: rooms::RoomMovedService }, + RoomCreated { + room_id: RoomId, + data: rooms::RoomCreatedService, + }, + RoomDeleted { + room_id: RoomId, + data: rooms::RoomDeletedService, + }, + RoomRenamed { + room_id: RoomId, + data: rooms::RoomRenamedService, + }, + RoomTopicUpdated { + room_id: RoomId, + data: rooms::RoomTopicUpdatedService, + }, + RoomSettingsUpdated { + room_id: RoomId, + data: rooms::RoomSettingsUpdatedService, + }, + RoomMoved { + room_id: RoomId, + data: rooms::RoomMovedService, + }, // ── Message events ── - MessageNew { room_id: RoomId, data: message::MessageNewService }, - MessageEdited { room_id: RoomId, data: message::MessageEditedService }, - MessageRevoked { room_id: RoomId, data: message::MessageRevokedService }, - MessageStreamStart { room_id: RoomId, data: message::MessageStreamStartService }, - MessageStreamChunk { room_id: RoomId, data: message::MessageStreamChunkService }, - MessageStreamDone { room_id: RoomId, data: message::MessageStreamDoneService }, + MessageNew { + room_id: RoomId, + data: message::MessageNewService, + }, + MessageEdited { + room_id: RoomId, + data: message::MessageEditedService, + }, + MessageRevoked { + room_id: RoomId, + data: message::MessageRevokedService, + }, + MessageStreamStart { + room_id: RoomId, + data: message::MessageStreamStartService, + }, + MessageStreamChunk { + room_id: RoomId, + data: message::MessageStreamChunkService, + }, + MessageStreamDone { + room_id: RoomId, + data: message::MessageStreamDoneService, + }, + MessageList { + room_id: RoomId, + data: message::MessageListService, + }, // ── Member events ── - MemberJoined { room_id: RoomId, data: member::MemberJoinedService }, - MemberRemoved { room_id: RoomId, data: member::MemberRemovedService }, - ReadReceipt { room_id: RoomId, data: member::ReadReceiptService }, - TypingStart { room_id: RoomId, data: member::TypingStartService }, - TypingStop { room_id: RoomId, data: member::TypingStopService }, + MemberJoined { + room_id: RoomId, + data: member::MemberJoinedService, + }, + MemberRemoved { + room_id: RoomId, + data: member::MemberRemovedService, + }, + ReadReceipt { + room_id: RoomId, + data: member::ReadReceiptService, + }, + TypingStart { + room_id: RoomId, + data: member::TypingStartService, + }, + TypingStop { + room_id: RoomId, + data: member::TypingStopService, + }, // ── Reaction events ── - ReactionAdded { room_id: RoomId, data: reaction::ReactionAddedService }, - ReactionRemoved { room_id: RoomId, data: reaction::ReactionRemovedService }, - ReactionBatchUpdated { room_id: RoomId, data: reaction::ReactionBatchUpdatedService }, + ReactionAdded { + room_id: RoomId, + data: reaction::ReactionAddedService, + }, + ReactionRemoved { + room_id: RoomId, + data: reaction::ReactionRemovedService, + }, + ReactionBatchUpdated { + room_id: RoomId, + data: reaction::ReactionBatchUpdatedService, + }, // ── Thread events ── - ThreadCreated { room_id: RoomId, data: thread::ThreadCreatedService }, - ThreadUpdated { room_id: RoomId, data: thread::ThreadUpdatedService }, - ThreadResolved { room_id: RoomId, data: thread::ThreadResolvedService }, - ThreadArchived { room_id: RoomId, data: thread::ThreadArchivedService }, - ThreadParticipantJoined { room_id: RoomId, data: thread::ThreadParticipantJoinedService }, - ThreadParticipantLeft { room_id: RoomId, data: thread::ThreadParticipantLeftService }, + ThreadCreated { + room_id: RoomId, + data: thread::ThreadCreatedService, + }, + ThreadUpdated { + room_id: RoomId, + data: thread::ThreadUpdatedService, + }, + ThreadResolved { + room_id: RoomId, + data: thread::ThreadResolvedService, + }, + ThreadArchived { + room_id: RoomId, + data: thread::ThreadArchivedService, + }, + ThreadParticipantJoined { + room_id: RoomId, + data: thread::ThreadParticipantJoinedService, + }, + ThreadParticipantLeft { + room_id: RoomId, + data: thread::ThreadParticipantLeftService, + }, // ── Category events ── - CategoryCreated { project: ProjectId, data: category::CategoryCreatedService }, - CategoryUpdated { project: ProjectId, data: category::CategoryUpdatedService }, - CategoryDeleted { project: ProjectId, data: category::CategoryDeletedService }, + CategoryCreated { + project: ProjectId, + data: category::CategoryCreatedService, + }, + CategoryUpdated { + project: ProjectId, + data: category::CategoryUpdatedService, + }, + CategoryDeleted { + project: ProjectId, + data: category::CategoryDeletedService, + }, // ── Pin events ── - PinAdded { room_id: RoomId, data: pin::PinAddedService }, - PinRemoved { room_id: RoomId, data: pin::PinRemovedService }, + PinAdded { + room_id: RoomId, + data: pin::PinAddedService, + }, + PinRemoved { + room_id: RoomId, + data: pin::PinRemovedService, + }, // ── Project events ── - ProjectRoomCreated { project: ProjectId, data: project::ProjectRoomCreatedService }, - ProjectRoomDeleted { project: ProjectId, data: project::ProjectRoomDeletedService }, - ProjectRoomRenamed { project: ProjectId, data: project::ProjectRoomRenamedService }, - ProjectRoomMoved { project: ProjectId, data: project::ProjectRoomMovedService }, + ProjectRoomCreated { + project: ProjectId, + data: project::ProjectRoomCreatedService, + }, + ProjectRoomDeleted { + project: ProjectId, + data: project::ProjectRoomDeletedService, + }, + ProjectRoomRenamed { + project: ProjectId, + data: project::ProjectRoomRenamedService, + }, + ProjectRoomMoved { + project: ProjectId, + data: project::ProjectRoomMovedService, + }, // ── Draft events ── - DraftSaved { room_id: RoomId, data: draft::DraftSavedService }, - DraftCleared { room_id: RoomId, data: draft::DraftClearedService }, + DraftSaved { + room_id: RoomId, + data: draft::DraftSavedService, + }, + DraftCleared { + room_id: RoomId, + data: draft::DraftClearedService, + }, // ── Search ── - SearchResult { data: search::SearchResultService }, + SearchResult { + data: search::SearchResultService, + }, // ── Notification events ── - NotifyCreated { data: notify::NotifyCreatedService }, - NotifyRead { data: notify::NotifyReadService }, + NotifyCreated { + data: notify::NotifyCreatedService, + }, + NotifyRead { + data: notify::NotifyReadService, + }, // ── Presence events ── - PresenceChanged { data: presence::PresenceChangedService }, - CustomStatusUpdated { data: presence::CustomStatusUpdatedService }, + PresenceChanged { + data: presence::PresenceChangedService, + }, + CustomStatusUpdated { + data: presence::CustomStatusUpdatedService, + }, // ── Invite events ── - InviteCreated { data: invite::InviteCreatedService }, - InviteAccepted { data: invite::InviteAcceptedService }, - InviteRejected { data: invite::InviteRejectedService }, - InviteRevoked { data: invite::InviteRevokedService }, + InviteCreated { + data: invite::InviteCreatedService, + }, + InviteAccepted { + data: invite::InviteAcceptedService, + }, + InviteRejected { + data: invite::InviteRejectedService, + }, + InviteRevoked { + data: invite::InviteRevokedService, + }, // ── Attachment events ── - AttachmentUploaded { data: attachment::AttachmentUploadedService }, - AttachmentThumbnailGenerated { data: attachment::AttachmentThumbnailService }, - AttachmentDeleted { data: attachment::AttachmentDeletedService }, + AttachmentUploaded { + data: attachment::AttachmentUploadedService, + }, + AttachmentThumbnailGenerated { + data: attachment::AttachmentThumbnailService, + }, + AttachmentDeleted { + data: attachment::AttachmentDeletedService, + }, // ── Ban events ── - UserBanned { data: ban::BannedService }, - UserUnbanned { data: ban::UnbannedService }, + UserBanned { + data: ban::BannedService, + }, + UserUnbanned { + data: ban::UnbannedService, + }, // ── AI events ── - AiAgentJoined { data: ai::AiAgentJoinedService }, - AiAgentLeft { data: ai::AiAgentLeftService }, - AiAgentStatusChanged { data: ai::AiAgentStatusChangedService }, + AiAgentJoined { + data: ai::AiAgentJoinedService, + }, + AiAgentLeft { + data: ai::AiAgentLeftService, + }, + AiAgentStatusChanged { + data: ai::AiAgentStatusChangedService, + }, // ── Voice events ── - VoiceChannelJoined { data: voice::VoiceChannelJoinedService }, - VoiceChannelLeft { data: voice::VoiceChannelLeftService }, - VoiceMuteUpdated { data: voice::VoiceMuteUpdatedService }, - VoiceDeafUpdated { data: voice::VoiceDeafUpdatedService }, - ScreenShareStarted { data: voice::ScreenShareStartedService }, - ScreenShareStopped { data: voice::ScreenShareStoppedService }, - SpeakingStarted { room_id: RoomId, user_id: UserId }, - SpeakingStopped { room_id: RoomId, user_id: UserId }, + VoiceChannelJoined { + data: voice::VoiceChannelJoinedService, + }, + VoiceChannelLeft { + data: voice::VoiceChannelLeftService, + }, + VoiceMuteUpdated { + data: voice::VoiceMuteUpdatedService, + }, + VoiceDeafUpdated { + data: voice::VoiceDeafUpdatedService, + }, + ScreenShareStarted { + data: voice::ScreenShareStartedService, + }, + ScreenShareStopped { + data: voice::ScreenShareStoppedService, + }, + SpeakingStarted { + room_id: RoomId, + user_id: UserId, + }, + SpeakingStopped { + room_id: RoomId, + user_id: UserId, + }, + // ── Request-response ── + Response { + request_id: Uuid, + data: serde_json::Value, + }, } #[derive(Debug, Clone, Serialize)] @@ -114,14 +288,31 @@ pub struct WsError { pub enum WsInMessage { Ping, // ── Room subscribe/unsubscribe ── - Subscribe { room: RoomId }, - Unsubscribe { room: RoomId }, + Subscribe { + room: RoomId, + }, + Unsubscribe { + room: RoomId, + }, // ── Typing ── - TypingStart { room: RoomId }, - TypingStop { room: RoomId }, + TypingStart { + room: RoomId, + }, + TypingStop { + room: RoomId, + }, // ── Read receipt ── - ReadReceipt { room: RoomId, last_read_seq: i64 }, + ReadReceipt { + room: RoomId, + last_read_seq: i64, + }, // ── Message operations ── + MessageList { + room: RoomId, + before_seq: Option, + after_seq: Option, + limit: Option, + }, MessageCreate { room: RoomId, content: String, @@ -129,34 +320,105 @@ pub enum WsInMessage { thread: Option, in_reply_to: Option, }, - MessageUpdate { message: Uuid, content: String }, - MessageRevoke { message: Uuid }, + MessageUpdate { + message: Uuid, + content: String, + }, + MessageRevoke { + message: Uuid, + }, + // ── Room queries ── + RoomGet { + room: RoomId, + }, // ── Room CRUD ── - RoomCreate { project: ProjectId, room_name: String, public: bool, category: Option }, - RoomUpdate { room: RoomId, room_name: Option, public: Option, category: Option }, - RoomDelete { room: RoomId }, + RoomCreate { + project: ProjectId, + room_name: String, + public: bool, + category: Option, + }, + RoomUpdate { + room: RoomId, + room_name: Option, + public: Option, + category: Option, + }, + RoomDelete { + room: RoomId, + }, // ── Category CRUD ── - CategoryCreate { project: ProjectId, name: String, position: Option }, - CategoryUpdate { id: Uuid, name: Option, position: Option }, - CategoryDelete { id: Uuid }, + CategoryCreate { + project: ProjectId, + name: String, + position: Option, + }, + CategoryUpdate { + id: Uuid, + name: Option, + position: Option, + }, + CategoryDelete { + id: Uuid, + }, // ── Access & state operations ── - AccessGrant { room: RoomId, user: UserId }, - AccessRevoke { room: RoomId, user: UserId }, - StateSetReadSeq { room: RoomId, last_read_seq: i64 }, - StateUpdateDnd { room: RoomId, do_not_disturb: Option, dnd_start_hour: Option, dnd_end_hour: Option }, + AccessGrant { + room: RoomId, + user: UserId, + }, + AccessRevoke { + room: RoomId, + user: UserId, + }, + StateSetReadSeq { + room: RoomId, + last_read_seq: i64, + }, + StateUpdateDnd { + room: RoomId, + do_not_disturb: Option, + dnd_start_hour: Option, + dnd_end_hour: Option, + }, // ── Reaction ── - ReactionAdd { room: RoomId, message: Uuid, emoji: String }, - ReactionRemove { room: RoomId, message: Uuid, emoji: String }, + ReactionAdd { + room: RoomId, + message: Uuid, + emoji: String, + }, + ReactionRemove { + room: RoomId, + message: Uuid, + emoji: String, + }, // ── Thread ── - ThreadCreate { room: RoomId, parent: i64 }, - ThreadResolve { thread_id: RoomThreadId }, - ThreadArchive { thread_id: RoomThreadId }, + ThreadCreate { + room: RoomId, + parent: i64, + }, + ThreadResolve { + thread_id: RoomThreadId, + }, + ThreadArchive { + thread_id: RoomThreadId, + }, // ── Pin ── - PinAdd { room: RoomId, message: Uuid }, - PinRemove { room: RoomId, message: Uuid }, + PinAdd { + room: RoomId, + message: Uuid, + }, + PinRemove { + room: RoomId, + message: Uuid, + }, // ── Draft ── - DraftSave { room: RoomId, content: String }, - DraftClear { room: RoomId }, + DraftSave { + room: RoomId, + content: String, + }, + DraftClear { + room: RoomId, + }, // ── Search ── Search { q: String, @@ -169,27 +431,71 @@ pub enum WsInMessage { offset: Option, }, // ── Notification ── - NotificationMarkRead { id: Uuid }, - NotificationMarkAllRead { project_id: Option }, - NotificationArchive { id: Uuid }, + NotificationMarkRead { + id: Uuid, + }, + NotificationMarkAllRead { + project_id: Option, + }, + NotificationArchive { + id: Uuid, + }, // ── Presence ── - PresenceUpdate { status: crate::event::presence::UserPresenceStatus }, - CustomStatusUpdate { emoji: Option, text: Option, expires_at: Option> }, + PresenceUpdate { + status: crate::event::presence::UserPresenceStatus, + }, + CustomStatusUpdate { + emoji: Option, + text: Option, + expires_at: Option>, + }, // ── Invite ── - InviteCreate { project: ProjectId, room: Option, max_uses: Option, expires_at: Option> }, - InviteAccept { code: String }, - InviteRevoke { id: Uuid }, + InviteCreate { + project: ProjectId, + room: Option, + max_uses: Option, + expires_at: Option>, + }, + InviteAccept { + code: String, + }, + InviteRevoke { + id: Uuid, + }, // ── Ban ── - BanCreate { project: ProjectId, user: UserId, reason: Option, expires_at: Option> }, - BanRemove { project: ProjectId, user: UserId }, + BanCreate { + project: ProjectId, + user: UserId, + reason: Option, + expires_at: Option>, + }, + BanRemove { + project: ProjectId, + user: UserId, + }, // ── Voice ── - VoiceJoin { room: RoomId }, - VoiceLeave { room: RoomId }, - VoiceMute { room: RoomId, muted: bool }, - VoiceDeaf { room: RoomId, deafened: bool }, - ScreenShare { room: RoomId, start: bool }, + VoiceJoin { + room: RoomId, + }, + VoiceLeave { + room: RoomId, + }, + VoiceMute { + room: RoomId, + muted: bool, + }, + VoiceDeaf { + room: RoomId, + deafened: bool, + }, + ScreenShare { + room: RoomId, + start: bool, + }, // ── AI ── - AiList { room: RoomId }, + AiList { + room: RoomId, + }, AiUpsert { room: RoomId, model: Uuid, @@ -199,7 +505,17 @@ pub enum WsInMessage { max_tokens: Option, stream: Option, }, - AiDelete { room: RoomId, agent_id: Uuid }, + AiDelete { + room: RoomId, + agent_id: Uuid, + }, + AiStop { + room: RoomId, + }, + // ── User ── + UserSummary { + username: String, + }, } impl WsInMessage { @@ -232,9 +548,12 @@ impl WsInMessage { | Self::AiList { room } | Self::AiUpsert { room, .. } | Self::AiDelete { room, .. } - | Self::Search { room: Some(room), .. } - => Some(*room), + | Self::MessageList { room, .. } + | Self::RoomGet { room } + | Self::Search { + room: Some(room), .. + } => Some(*room), _ => None, } } -} \ No newline at end of file +} diff --git a/libs/transport/handler/ws.rs b/libs/transport/handler/ws.rs index 1ac1d6d..06f3c58 100644 --- a/libs/transport/handler/ws.rs +++ b/libs/transport/handler/ws.rs @@ -1,8 +1,10 @@ +use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::time::Instant; use actix_web::{web, HttpRequest, HttpResponse}; use actix_ws::Message as WsMessage; +use futures_util::FutureExt; use uuid::Uuid; use service::AppService; @@ -25,9 +27,25 @@ pub async fn ws_handler( req: HttpRequest, stream: web::Payload, ) -> Result { - let user_id = authenticate_ws(&service, &req).await?; + let auth_ctx = authenticate_ws(&service, &req).await?; + let user_id = auth_ctx.user_id; - tracing::info!(user_id = %user_id, "WS transport connection established"); + // Resolve display name for this user (cached in WsUserCtx for typing events, etc.) + let display_name = { + use models::users::user as user_model; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let db = &service.db; + user_model::Entity::find() + .filter(user_model::Column::Uid.eq(user_id)) + .one(db) + .await + .ok() + .flatten() + .map(|u| u.display_name.unwrap_or_else(|| u.username)) + .unwrap_or_else(|| user_id.to_string()) + }; + + tracing::info!(user_id = %user_id, display_name = %display_name, "WS transport connection established"); let service_arc = Arc::new(service.get_ref().clone()); let manager = service_arc.room.room_manager.clone(); @@ -39,132 +57,212 @@ pub async fn ws_handler( let (response, mut ws_session, mut msg_stream) = actix_ws::handle(&req, stream)?; - actix::spawn(async move { - let session = TransportSession::new( - WsUserCtx { user_id, device_id: String::new(), client_id: String::new() }, - service_arc, - ); + let spawn_handle = actix::spawn(async move { + let panic_result = AssertUnwindSafe(async { + let session = TransportSession::new( + WsUserCtx { + user_id, + device_id: auth_ctx.device_id, + client_id: auth_ctx.client_id, + display_name, + }, + service_arc, + ); - // Split state for tokio::select! borrow safety - let mut last_heartbeat = Instant::now(); - let mut last_activity = Instant::now(); - let mut message_count: u32 = 0; - let mut rate_window_start = Instant::now(); - let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL); - heartbeat_interval.tick().await; + // Split state for tokio::select! borrow safety + let mut last_heartbeat = Instant::now(); + let mut last_activity = Instant::now(); + let mut message_count: u32 = 0; + let mut rate_window_start = Instant::now(); + let mut heartbeat_interval = tokio::time::interval(HEARTBEAT_INTERVAL); + heartbeat_interval.tick().await; - loop { - tokio::select! { - // ── Heartbeat ── - _ = heartbeat_interval.tick() => { - if last_heartbeat.elapsed() > HEARTBEAT_TIMEOUT { - tracing::warn!(user_id = %user_id, "WS transport heartbeat timeout"); - manager.metrics.ws_heartbeat_timeout_total.increment(1); - let _ = ws_session.close(Some(actix_ws::CloseCode::Policy.into())).await; - break; + loop { + tokio::select! { + // ── Heartbeat ── + _ = heartbeat_interval.tick() => { + if last_heartbeat.elapsed() > HEARTBEAT_TIMEOUT { + tracing::warn!(user_id = %user_id, "WS transport heartbeat timeout"); + manager.metrics.ws_heartbeat_timeout_total.increment(1); + let _ = ws_session.close(Some(actix_ws::CloseCode::Policy.into())).await; + break; + } + if last_activity.elapsed() > MAX_IDLE_TIMEOUT { + tracing::info!(user_id = %user_id, "WS transport idle timeout"); + manager.metrics.ws_idle_timeout_total.increment(1); + let _ = ws_session.close(Some(actix_ws::CloseCode::Normal.into())).await; + break; + } + if ws_session.ping(b"").await.is_err() { break; } + manager.metrics.ws_heartbeat_sent_total.increment(1); } - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { - tracing::info!(user_id = %user_id, "WS transport idle timeout"); - manager.metrics.ws_idle_timeout_total.increment(1); + // ── Shutdown ── + _ = shutdown_rx.recv() => { + tracing::info!("WS transport shutdown"); let _ = ws_session.close(Some(actix_ws::CloseCode::Normal.into())).await; break; } - if ws_session.ping(b"").await.is_err() { break; } - manager.metrics.ws_heartbeat_sent_total.increment(1); - } - // ── Shutdown ── - _ = shutdown_rx.recv() => { - tracing::info!("WS transport shutdown"); - let _ = ws_session.close(Some(actix_ws::CloseCode::Normal.into())).await; - break; - } - // ── Notification push ── - notif = poll_notifications(&mut notif_rx) => { - if let Some(event) = notif { - if send_event(&mut ws_session, &event).await.is_err() { break; } - } - } - // ── Room broadcast push ── - push = poll_subscriptions(&session) => { - if let Some(event) = push { - if send_event(&mut ws_session, &event).await.is_err() { break; } - } - } - // ── Inbound client message ── - msg = msg_stream.recv() => { - match msg { - Some(Ok(WsMessage::Ping(bytes))) => { - if ws_session.pong(&bytes).await.is_err() { break; } - last_heartbeat = Instant::now(); + // ── Notification push ── + notif = poll_notifications(&mut notif_rx) => { + if let Some(event) = notif { + if send_event(&mut ws_session, &event).await.is_err() { break; } } - Some(Ok(WsMessage::Pong(_))) => { last_heartbeat = Instant::now(); } - Some(Ok(WsMessage::Text(text))) => { - if last_activity.elapsed() > MAX_IDLE_TIMEOUT { break; } - last_activity = Instant::now(); - last_heartbeat = Instant::now(); + } + // ── Room broadcast push ── + push = poll_subscriptions(&session) => { + if let Some(event) = push { + if send_event(&mut ws_session, &event).await.is_err() { break; } + } + } + // ── Inbound client message ── + msg = msg_stream.recv() => { + match msg { + Some(Ok(WsMessage::Ping(bytes))) => { + if ws_session.pong(&bytes).await.is_err() { break; } + last_heartbeat = Instant::now(); + } + Some(Ok(WsMessage::Pong(_))) => { last_heartbeat = Instant::now(); } + Some(Ok(WsMessage::Text(text))) => { + if last_activity.elapsed() > MAX_IDLE_TIMEOUT { break; } + last_activity = Instant::now(); + last_heartbeat = Instant::now(); - // Rate limit - if rate_window_start.elapsed() > super::session::RATE_LIMIT_WINDOW { - message_count = 0; - rate_window_start = Instant::now(); - } - message_count += 1; - if message_count > MAX_MESSAGES_PER_SECOND { - let _ = ws_session.text(serde_json::json!({ - "type": "error", "error": "rate_limit_exceeded" - }).to_string()).await; - continue; - } - if text.len() > MAX_TEXT_MESSAGE_LEN { - let _ = ws_session.text(serde_json::json!({ - "type": "error", "error": "message_too_long" - }).to_string()).await; - continue; - } - // Application-level ping - if text.trim() == r#"{"type":"ping"}"# { - if ws_session.text(r#"{"type":"pong"}"#).await.is_err() { break; } - continue; - } - match serde_json::from_str::(&text) { - Ok(in_msg) => { - if let Ok(response) = MessageHandler::handle(&session, in_msg).await { - if let Some(event) = response { - if send_event(&mut ws_session, &event).await.is_err() { break; } + // Rate limit + if rate_window_start.elapsed() > super::session::RATE_LIMIT_WINDOW { + message_count = 0; + rate_window_start = Instant::now(); + } + message_count += 1; + if message_count > MAX_MESSAGES_PER_SECOND { + let _ = ws_session.text(serde_json::json!({ + "type": "error", "error": "rate_limit_exceeded" + }).to_string()).await; + continue; + } + if text.len() > MAX_TEXT_MESSAGE_LEN { + let _ = ws_session.text(serde_json::json!({ + "type": "error", "error": "message_too_long" + }).to_string()).await; + continue; + } + + // Pre-parse as Value to extract _request_id, then parse as WsInMessage + let json_value = serde_json::from_str::(&text); + + // Application-level JSON ping (distinguish from WebSocket Ping frame) + if text.trim() == r#"{"type":"ping"}"# || text.trim() == r#"{"type":"ping","_request_id":null}"# { + if ws_session.text(r#"{"type":"pong"}"#).await.is_err() { break; } + continue; + } + + // Extract _request_id before full parsing to support request-response correlation + let request_id: Option = json_value + .ok() + .and_then(|v| v.get("_request_id") + .and_then(|r| serde_json::from_value(r.clone()).ok())); + + // Re-parse from text as WsInMessage for the handler + match serde_json::from_str::(&text) { + Ok(in_msg) => { + match MessageHandler::handle(&session, in_msg).await { + Ok(Some(event)) => { + if let Some(rid) = request_id { + // Unwrap inner data for MessageList to avoid double nesting + let json_data = match &event { + WsOutEvent::MessageList { data, .. } => { + serde_json::to_value(data).unwrap_or_default() + } + _ => { + serde_json::to_value(&event).unwrap_or_default() + } + }; + let resp = WsOutEvent::Response { request_id: rid, data: json_data }; + if send_event(&mut ws_session, &resp).await.is_err() { break; } + } else { + if send_event(&mut ws_session, &event).await.is_err() { break; } + } + } + Ok(None) => { + if let Some(rid) = request_id { + let ack = WsOutEvent::Response { + request_id: rid, + data: serde_json::json!({"ok": true}), + }; + if send_event(&mut ws_session, &ack).await.is_err() { break; } + } else { + let _ = ws_session.text(r#"{"type":"ack"}"#).await; + } + } + Err(e) => { + tracing::warn!(user_id = %user_id, error = %e, "WS message processing failed"); + let err_json = if let Some(rid) = request_id { + serde_json::json!({ + "type": "error", + "code": 500, + "error": "internal_error", + "message": e.to_string(), + "_request_id": rid + }) + } else { + serde_json::json!({ + "type": "error", + "code": 500, + "error": "internal_error", + "message": e.to_string() + }) + }; + let _ = ws_session.text(err_json.to_string()).await; + } } } - } - Err(e) => { - tracing::warn!(error = %e, "WS transport parse error"); - let _ = ws_session.text(serde_json::json!({ - "type": "error", "error": "parse_error" - }).to_string()).await; + Err(e) => { + tracing::warn!(error = %e, "WS transport parse error"); + let _ = ws_session.text(serde_json::json!({ + "type": "error", "error": "parse_error" + }).to_string()).await; + } } } + Some(Ok(WsMessage::Binary(_))) => { break; } + Some(Ok(WsMessage::Continuation(_))) => {} + Some(Ok(WsMessage::Nop)) => {} + Some(Ok(WsMessage::Close(reason))) => { + let _ = ws_session.close(reason).await; + break; + } + Some(Err(e)) => { tracing::warn!(error = %e, "WS transport error"); break; } + None => break, } - Some(Ok(WsMessage::Binary(_))) => { break; } - Some(Ok(WsMessage::Continuation(_))) => {} - Some(Ok(WsMessage::Nop)) => {} - Some(Ok(WsMessage::Close(reason))) => { - let _ = ws_session.close(reason).await; - break; - } - Some(Err(e)) => { tracing::warn!(error = %e, "WS transport error"); break; } - None => break, } } } - } - // Cleanup - for sub in session.subscriptions.iter() { - manager.unsubscribe(sub.room_id, user_id).await; + // Cleanup + for sub in session.subscriptions.iter() { + manager.unsubscribe(sub.room_id, user_id).await; + } + manager.unsubscribe_user_notification(user_id).await; + manager.metrics.ws_connections_active.decrement(1.0); + manager.metrics.ws_disconnections_total.increment(1); + }).catch_unwind(); + + if let Err(panic_err) = panic_result.await { + let panic_msg = if let Some(s) = panic_err.downcast_ref::() { + s.clone() + } else if let Some(s) = panic_err.downcast_ref::<&str>() { + s.to_string() + } else { + "Unknown panic".to_string() + }; + tracing::error!(user_id = %user_id, panic = %panic_msg, "WS transport task panicked"); + manager.metrics.ws_connections_active.decrement(1.0); + manager.metrics.ws_disconnections_total.increment(1); } - manager.unsubscribe_user_notification(user_id).await; - manager.metrics.ws_connections_active.decrement(1.0); - manager.metrics.ws_disconnections_total.increment(1); }); + // Drop the handle intentionally — cleanup is handled inside the spawned task + drop(spawn_handle); + Ok(response) } @@ -181,18 +279,43 @@ async fn send_event(ws_session: &mut actix_ws::Session, event: &WsOutEvent) -> R async fn authenticate_ws( service: &AppService, req: &HttpRequest, -) -> Result { +) -> Result { + // Prefer Authorization header over query parameter + if let Some(auth_header) = req.headers().get("Authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + match service.ws_token.validate_token_ctx(token).await { + Ok(ctx) => return Ok(crate::token::AppTransportTokenContext { + user_id: ctx.user_id, + device_id: ctx.device_id.unwrap_or_default(), + client_id: ctx.client_id.unwrap_or_default(), + }), + Err(_) => { + service.room.room_manager.metrics.ws_auth_failures.increment(1); + return Err(actix_web::error::ErrorUnauthorized("token auth failed")); + } + } + } + } + } + + // Fallback: token in query string (deprecated, kept for backward compatibility) if let Some(token) = req.uri().query().and_then(|q| { q.split('&').find(|p| p.starts_with("token=")).and_then(|p| p.split('=').nth(1)) }) { - match service.ws_token.validate_token(token).await { - Ok(uid) => return Ok(uid), + match service.ws_token.validate_token_ctx(token).await { + Ok(ctx) => return Ok(crate::token::AppTransportTokenContext { + user_id: ctx.user_id, + device_id: ctx.device_id.unwrap_or_default(), + client_id: ctx.client_id.unwrap_or_default(), + }), Err(_) => { service.room.room_manager.metrics.ws_auth_failures.increment(1); return Err(actix_web::error::ErrorUnauthorized("token auth failed")); } } } + service.room.room_manager.metrics.ws_auth_failures.increment(1); Err(actix_web::error::ErrorUnauthorized("no auth provided")) } \ No newline at end of file diff --git a/libs/transport/security.rs b/libs/transport/security.rs index 263f2bd..0189c38 100644 --- a/libs/transport/security.rs +++ b/libs/transport/security.rs @@ -28,21 +28,26 @@ impl RateLimiter { let mut conn = self.cache.conn().await .map_err(|_| crate::error::AppTransportError::Internal)?; - let count: Option = conn.get(&key).await - .map_err(|_| crate::error::AppTransportError::Internal)?; - - let current = count.unwrap_or(0); - - if current >= self.max_requests { - return Ok(false); - } - - let new_count = current + 1; - let _: () = conn.set_ex(&key, new_count, self.window.as_secs()) + // Atomic INCR with EX NX — sets TTL only on first creation + let count: u32 = redis::Cmd::new() + .arg("INCR") + .arg(&key) + .query_async(&mut conn) .await .map_err(|_| crate::error::AppTransportError::Internal)?; - Ok(true) + // Set expiry only when the key is newly created (count == 1) + if count == 1 { + let _: () = redis::Cmd::new() + .arg("EXPIRE") + .arg(&key) + .arg(self.window.as_secs()) + .query_async(&mut conn) + .await + .map_err(|_| crate::error::AppTransportError::Internal)?; + } + + Ok(count <= self.max_requests) } pub async fn get_remaining( diff --git a/libs/transport/token.rs b/libs/transport/token.rs index 8e8e2ab..8894c33 100644 --- a/libs/transport/token.rs +++ b/libs/transport/token.rs @@ -96,7 +96,7 @@ impl AppTransport { .env .get("APP_SESSION_SECRET") .map(|s| s.as_str()) - .unwrap_or("fallback-transport-signing-key-not-for-production"); + .ok_or(AppTransportError::Internal)?; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .map_err(|_| AppTransportError::Internal)?; mac.update(b"transport-access-token-signing-key"); diff --git a/libs/transport/unread.rs b/libs/transport/unread.rs index 6a401dd..908982f 100644 --- a/libs/transport/unread.rs +++ b/libs/transport/unread.rs @@ -102,6 +102,11 @@ impl UnreadManager { .await .map_err(|_| crate::error::AppTransportError::Internal)?; + if states.is_empty() { + return Ok(Vec::new()); + } + + let now = chrono::Utc::now(); let mut result = Vec::new(); for state in states { @@ -120,7 +125,7 @@ impl UnreadManager { user_id, count: count as i64, last_read_seq, - updated_at: chrono::Utc::now(), + updated_at: now, }); } } diff --git a/libs/webhook/Cargo.toml b/libs/webhook/Cargo.toml deleted file mode 100644 index d7c27eb..0000000 --- a/libs/webhook/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "webhook" -version.workspace = true -edition.workspace = true -authors.workspace = true -description.workspace = true -repository.workspace = true -readme.workspace = true -homepage.workspace = true -license.workspace = true -keywords.workspace = true -categories.workspace = true -documentation.workspace = true -[lib] -path = "lib.rs" -name = "webhook" -[dependencies] - -[lints] -workspace = true diff --git a/libs/webhook/lib.rs b/libs/webhook/lib.rs deleted file mode 100644 index b93cf3f..0000000 --- a/libs/webhook/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} diff --git a/openapi.json b/openapi.json index d0d3108..17a23b0 100644 --- a/openapi.json +++ b/openapi.json @@ -1355,6 +1355,587 @@ } } }, + "/api/ai/conversations": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_list", + "parameters": [ + { + "name": "project_id", + "in": "query", + "description": "Filter by project", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List of conversations", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_ConversationResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateConversationParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Conversation created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ConversationResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/api/ai/conversations/{conversation_id}": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_get", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get conversation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ConversationResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "delete": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_delete", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Conversation deleted" + }, + "404": { + "description": "Not found" + } + } + }, + "patch": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_update", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateConversationParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Conversation updated" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_list", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "limit", + "in": "query", + "description": "Max messages", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "List messages", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_MessageResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + }, + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_create", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMessageParams" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Message created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MessageResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_get", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MessageResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}/children": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_children", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Parent message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "List child messages", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_MessageResponse" + } + } + } + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}/fork/{target_message_id}": { + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_fork", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Source message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "target_message_id", + "in": "path", + "description": "Target/fork message ID to create", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Fork created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ForkResponse" + } + } + } + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}/resend": { + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_resend", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Resend message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_MessageResponse" + } + } + } + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}/stop": { + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_message_stop", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Message stopped" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/messages/{message_id}/stream": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "message_stream", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "description": "Message ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "SSE stream" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/share": { + "post": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_conversation_share", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Share token created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ShareResponse" + } + } + } + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/ai/conversations/{conversation_id}/share/{share_token}": { + "get": { + "tags": [ + "AI Chat" + ], + "operationId": "ai_shared_conversation_get", + "parameters": [ + { + "name": "conversation_id", + "in": "path", + "description": "Conversation ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "share_token", + "in": "path", + "description": "Share token", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get shared conversation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_ConversationResponse" + } + } + } + }, + "404": { + "description": "Not found or expired" + } + } + } + }, "/api/auth/2fa/disable": { "post": { "tags": [ @@ -4385,6 +4966,46 @@ } } }, + "/api/project/{project_id}/presence": { + "get": { + "tags": [ + "Presence" + ], + "operationId": "project_presence", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get project presence", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_Vec_PresenceChanged" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + } + } + }, "/api/project_room/{project_name}/room-categories": { "get": { "tags": [ @@ -4996,6 +5617,45 @@ } } }, + "/api/projects/{project_name}/billing/errors": { + "get": { + "tags": [ + "Project" + ], + "operationId": "project_billing_errors", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get project billing errors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_BillingErrorsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + } + } + }, "/api/projects/{project_name}/billing/history": { "get": { "tags": [ @@ -6581,6 +7241,42 @@ } } }, + "/api/projects/{project_name}/members/grouped": { + "get": { + "tags": [ + "Project" + ], + "operationId": "project_members_grouped", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List project members grouped by role", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_GroupedMemberListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + } + }, "/api/projects/{project_name}/members/role": { "patch": { "tags": [ @@ -6746,6 +7442,129 @@ } } }, + "/api/projects/{project_name}/role-priorities": { + "get": { + "tags": [ + "Project" + ], + "operationId": "project_role_priorities", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List project role priorities", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RolePriorityListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, + "post": { + "tags": [ + "Project" + ], + "operationId": "project_upsert_role_priority", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertRolePriorityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upsert role priority", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RolePriorityInfo" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + } + } + }, + "/api/projects/{project_name}/role-priorities/{role_key}": { + "delete": { + "tags": [ + "Project" + ], + "operationId": "project_delete_role_priority", + "parameters": [ + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "role_key", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Delete role priority" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not found" + } + } + } + }, "/api/projects/{project_name}/settings/name": { "patch": { "tags": [ @@ -19403,6 +20222,69 @@ } }, "/api/rooms/{room_id}/messages": { + "get": { + "tags": [ + "Room" + ], + "operationId": "message_list", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "before_seq", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "after_seq", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "List room messages", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RoomMessageListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, "post": { "tags": [ "Room" @@ -19518,6 +20400,50 @@ } }, "/api/rooms/{room_id}/messages/{message_id}": { + "get": { + "tags": [ + "Room" + ], + "operationId": "message_get", + "parameters": [ + { + "name": "room_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "message_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "Get room message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_RoomMessageResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not found" + } + } + }, "patch": { "tags": [ "Room" @@ -20321,6 +21247,121 @@ } } }, + "/api/users/me/avatar": { + "post": { + "tags": [ + "User" + ], + "summary": "Upload current user's avatar.\nAccepts a multipart form with a single file field.", + "operationId": "upload_avatar", + "responses": { + "200": { + "description": "Avatar uploaded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_AvatarUploadResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/api/users/me/billing": { + "get": { + "tags": [ + "User" + ], + "operationId": "user_billing", + "responses": { + "200": { + "description": "Get user billing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserBillingResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/api/users/me/billing/errors": { + "get": { + "tags": [ + "User" + ], + "operationId": "user_billing_errors", + "responses": { + "200": { + "description": "Get user billing errors", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserBillingErrorsResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/api/users/me/billing/history": { + "get": { + "tags": [ + "User" + ], + "operationId": "user_billing_history", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Get user billing history", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_UserBillingHistoryResponse" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, "/api/users/me/heatmap": { "get": { "tags": [ @@ -21198,180 +22239,15 @@ } } }, - "/api/workspaces": { - "post": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_create", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceInitParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Create workspace", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceInfoResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "409": { - "description": "Slug or name already exists" - } - } - } - }, - "/api/workspaces/invitations/accept": { - "post": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_accept_invitation", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceInviteAcceptParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Accept invitation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceInfoResponse" - } - } - } - }, - "400": { - "description": "Invalid or expired token" - }, - "401": { - "description": "Unauthorized" - }, - "409": { - "description": "Already accepted" - } - } - } - }, - "/api/workspaces/invitations/accept-by-slug": { - "post": { - "tags": [ - "Workspace" - ], - "summary": "Accept a workspace invitation by slug.", - "operationId": "workspace_accept_invitation_by_slug", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceAcceptBySlugParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Accept invitation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceInfoResponse" - } - } - } - }, - "400": { - "description": "Invalid or expired token" - }, - "401": { - "description": "Unauthorized" - }, - "404": { - "description": "Invitation not found" - }, - "409": { - "description": "Already accepted" - } - } - } - }, - "/api/workspaces/me": { + "/api/users/{username}/summary": { "get": { "tags": [ - "Workspace" + "User" ], - "operationId": "workspace_list", - "responses": { - "200": { - "description": "List my workspaces", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceListResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - } - } - }, - "/api/workspaces/me/invitations": { - "get": { - "tags": [ - "Workspace" - ], - "summary": "List all pending workspace invitations for the current user.", - "operationId": "workspace_my_invitations", - "responses": { - "200": { - "description": "List my workspace invitations", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_MyWorkspaceInvitation" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - } - } - }, - "/api/workspaces/{slug}": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_info", + "operationId": "get_user_summary", "parameters": [ { - "name": "slug", + "name": "username", "in": "path", "required": true, "schema": { @@ -21381,11 +22257,11 @@ ], "responses": { "200": { - "description": "Get workspace info", + "description": "Get user summary for dashboard", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceInfoResponse" + "$ref": "#/components/schemas/ApiResponse_UserSummaryResponse" } } } @@ -21394,553 +22270,7 @@ "description": "Unauthorized" }, "404": { - "description": "Workspace not found" - } - } - }, - "delete": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_delete", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Delete workspace" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied (owner only)" - }, - "404": { - "description": "Workspace not found" - } - } - }, - "patch": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_update", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceUpdateParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Update workspace", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceInfoResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "Workspace not found" - }, - "409": { - "description": "Name already exists" - } - } - } - }, - "/api/workspaces/{slug}/billing": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_billing_current", - "parameters": [ - { - "name": "slug", - "in": "path", - "description": "Workspace slug", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get workspace billing info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceBillingCurrentResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Not a workspace member" - }, - "404": { - "description": "Workspace not found" - } - } - } - }, - "/api/workspaces/{slug}/billing/credits": { - "post": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_billing_add_credit", - "parameters": [ - { - "name": "slug", - "in": "path", - "description": "Workspace slug", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceBillingAddCreditParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Add credit to workspace billing", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceBillingCurrentResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Not a workspace member" - } - } - } - }, - "/api/workspaces/{slug}/billing/history": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_billing_history", - "parameters": [ - { - "name": "slug", - "in": "path", - "description": "Workspace slug", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get workspace billing history", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceBillingHistoryResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - } - } - } - }, - "/api/workspaces/{slug}/invitations": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_pending_invitations", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List pending invitations", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_Vec_PendingInvitationInfo" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "Workspace not found" - } - } - }, - "post": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_invite_member", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorkspaceInviteParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Send invitation" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "User not found" - }, - "409": { - "description": "Already a member" - } - } - } - }, - "/api/workspaces/{slug}/invitations/{user_id}": { - "delete": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_cancel_invitation", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "Cancel invitation" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "Invitation not found" - } - } - } - }, - "/api/workspaces/{slug}/members": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_members", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - }, - { - "name": "per_page", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "List workspace members", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceMembersResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Not a member" - }, - "404": { - "description": "Workspace not found" - } - } - } - }, - "/api/workspaces/{slug}/members/role": { - "patch": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_update_member_role", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateRoleParams" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Update member role" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "Workspace or member not found" - } - } - } - }, - "/api/workspaces/{slug}/members/{user_id}": { - "delete": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_remove_member", - "parameters": [ - { - "name": "slug", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "format": "uuid" - } - } - ], - "responses": { - "200": { - "description": "Remove member" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Permission denied" - }, - "404": { - "description": "Member not found" - } - } - } - }, - "/api/workspaces/{slug}/projects": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_projects", - "parameters": [ - { - "name": "slug", - "in": "path", - "description": "Workspace slug", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "List workspace projects", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceProjectsResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Not a workspace member" - }, - "404": { - "description": "Workspace not found" - } - } - } - }, - "/api/workspaces/{slug}/stats": { - "get": { - "tags": [ - "Workspace" - ], - "operationId": "workspace_stats", - "parameters": [ - { - "name": "slug", - "in": "path", - "description": "Workspace slug", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Get workspace stats", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse_WorkspaceStatsResponse" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Not a workspace member" - }, - "404": { - "description": "Workspace not found" + "description": "Not found" } } } @@ -22775,6 +23105,63 @@ } } }, + "ApiResponse_AvatarUploadResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "avatar_url" + ], + "properties": { + "avatar_url": { + "type": "string" + } + } + } + } + }, + "ApiResponse_BillingErrorsResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "list" + ], + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingErrorItem" + } + } + } + } + } + }, "ApiResponse_BlobContentResponse": { "type": "object", "required": [ @@ -24627,6 +25014,131 @@ } } }, + "ApiResponse_ConversationResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "id", + "user_id", + "scope", + "model", + "status", + "fork_count", + "is_shared", + "message_count", + "access_visibility", + "can_ask", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "model_config": {}, + "status": { + "type": "string" + }, + "root_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "fork_count": { + "type": "integer", + "format": "int32" + }, + "is_shared": { + "type": "boolean" + }, + "message_count": { + "type": "integer", + "format": "int32" + }, + "token_usage_total": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "access_visibility": { + "type": "string" + }, + "can_ask": { + "type": "string" + }, + "project_uid": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "model_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "model_name": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_DeleteSkillResponse": { "type": "object", "required": [ @@ -24819,6 +25331,56 @@ } } }, + "ApiResponse_ForkResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "id", + "source_message_id", + "fork_message_id", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "conversation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "source_message_id": { + "type": "string", + "format": "uuid" + }, + "fork_message_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_GitInitResponse": { "type": "object", "required": [ @@ -24953,6 +25515,42 @@ } } }, + "ApiResponse_GroupedMemberListResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "groups", + "total" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberGroup" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + }, "ApiResponse_InvitationListResponse": { "type": "object", "required": [ @@ -25397,6 +25995,7 @@ "title", "state", "author", + "labels", "created_at", "updated_at", "created_by_ai" @@ -25442,6 +26041,12 @@ "null" ] }, + "labels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueLabelResponse" + } + }, "created_at": { "type": "string", "format": "date-time" @@ -26015,6 +26620,118 @@ } } }, + "ApiResponse_MessageResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "id", + "conversation_id", + "role", + "content", + "is_fork_origin", + "version_number", + "is_latest", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "conversation_id": { + "type": "string", + "format": "uuid" + }, + "parent_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "role": { + "type": "string" + }, + "content": {}, + "model": { + "type": [ + "string", + "null" + ] + }, + "is_fork_origin": { + "type": "boolean" + }, + "stop_reason": { + "type": [ + "string", + "null" + ] + }, + "input_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "output_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "latency_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "metadata": {}, + "room_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "version_group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "version_number": { + "type": "integer", + "format": "int32" + }, + "is_latest": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_MessageSearchResponse": { "type": "object", "required": [ @@ -26416,6 +27133,7 @@ "required": [ "project_uid", "currency", + "is_pro", "monthly_quota", "balance", "month_used", @@ -26432,6 +27150,9 @@ "currency": { "type": "string" }, + "is_pro": { + "type": "boolean" + }, "monthly_quota": { "type": "number", "format": "double" @@ -27595,6 +28316,93 @@ } } }, + "ApiResponse_RolePriorityInfo": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "id", + "role_key", + "display_name", + "priority", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "role_key": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, + "ApiResponse_RolePriorityListResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "roles" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RolePriorityInfo" + } + } + } + } + } + }, "ApiResponse_RoomAiResponse": { "type": "object", "required": [ @@ -27922,6 +28730,12 @@ "type": "string", "format": "uuid" } + }, + "reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroupResponse" + } } } } @@ -28313,6 +29127,50 @@ } } }, + "ApiResponse_ShareResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "id", + "share_token", + "view_count" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "share_token": { + "type": "string" + }, + "view_count": { + "type": "integer", + "format": "int32" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + } + } + }, "ApiResponse_SideBySideDiffResponse": { "type": "object", "required": [ @@ -29232,6 +30090,159 @@ } } }, + "ApiResponse_UserBillingErrorsResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "list" + ], + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserBillingErrorItem" + } + } + } + } + } + }, + "ApiResponse_UserBillingHistoryResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "page", + "per_page", + "total", + "list" + ], + "properties": { + "page": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "per_page": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserBillingHistoryItem" + } + } + } + } + } + }, + "ApiResponse_UserBillingResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "user_uid", + "balance", + "currency", + "is_pro", + "monthly_quota", + "month_used", + "updated_at", + "created_at" + ], + "properties": { + "user_uid": { + "type": "string", + "format": "uuid" + }, + "balance": { + "type": "number", + "format": "double" + }, + "currency": { + "type": "string" + }, + "is_pro": { + "type": "boolean" + }, + "monthly_quota": { + "type": "number", + "format": "double" + }, + "month_used": { + "type": "number", + "format": "double" + }, + "cycle_start_utc": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "cycle_end_utc": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + } + } + }, "ApiResponse_UserInfoExternal": { "type": "object", "required": [ @@ -29450,6 +30461,76 @@ } } }, + "ApiResponse_UserSummaryResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "required": [ + "info", + "repos", + "projects", + "activity", + "heatmap", + "follower_count", + "following_count", + "stars_count" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/UserInfoExternal" + }, + "repos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRepoInfo" + } + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserProjectInfo" + } + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserActivityItem" + } + }, + "heatmap": { + "$ref": "#/components/schemas/ContributionHeatmapResponse" + }, + "follower_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "following_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "stars_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + } + } + }, "ApiResponse_Value": { "type": "object", "required": [ @@ -29871,6 +30952,134 @@ } } }, + "ApiResponse_Vec_ConversationResponse": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "user_id", + "scope", + "model", + "status", + "fork_count", + "is_shared", + "message_count", + "access_visibility", + "can_ask", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "model_config": {}, + "status": { + "type": "string" + }, + "root_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "fork_count": { + "type": "integer", + "format": "int32" + }, + "is_shared": { + "type": "boolean" + }, + "message_count": { + "type": "integer", + "format": "int32" + }, + "token_usage_total": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "access_visibility": { + "type": "string" + }, + "can_ask": { + "type": "string" + }, + "project_uid": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "model_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "model_name": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, "ApiResponse_Vec_IssueAssigneeResponse": { "type": "object", "required": [ @@ -30276,7 +31485,7 @@ } } }, - "ApiResponse_Vec_MyWorkspaceInvitation": { + "ApiResponse_Vec_MessageResponse": { "type": "object", "required": [ "code", @@ -30294,43 +31503,96 @@ "type": "array", "items": { "type": "object", - "description": "Invitation received by the current user (workspace invitation for self).", "required": [ - "workspace_id", - "workspace_slug", - "workspace_name", + "id", + "conversation_id", "role", - "invited_at" + "content", + "is_fork_origin", + "version_number", + "is_latest", + "created_at" ], "properties": { - "workspace_id": { + "id": { "type": "string", "format": "uuid" }, - "workspace_slug": { - "type": "string" + "conversation_id": { + "type": "string", + "format": "uuid" }, - "workspace_name": { - "type": "string" + "parent_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" }, "role": { "type": "string" }, - "invited_by_username": { + "content": {}, + "model": { "type": [ "string", "null" ] }, - "invited_at": { - "type": "string", - "format": "date-time" + "is_fork_origin": { + "type": "boolean" }, - "expires_at": { + "stop_reason": { + "type": [ + "string", + "null" + ] + }, + "input_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "output_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "latency_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "metadata": {}, + "room_id": { "type": [ "string", "null" ], + "format": "uuid" + }, + "version_group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "version_number": { + "type": "integer", + "format": "int32" + }, + "is_latest": { + "type": "boolean" + }, + "created_at": { + "type": "string", "format": "date-time" } } @@ -30338,7 +31600,7 @@ } } }, - "ApiResponse_Vec_PendingInvitationInfo": { + "ApiResponse_Vec_PresenceChanged": { "type": "object", "required": [ "code", @@ -30356,52 +31618,27 @@ "type": "array", "items": { "type": "object", + "description": "Presence changed event for broadcasting.", "required": [ "user_id", - "username", - "role", - "invited_at" + "status" ], "properties": { "user_id": { "type": "string", "format": "uuid" }, - "username": { - "type": "string" - }, - "display_name": { + "project_id": { "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "avatar_url": { - "type": [ - "string", - "null" - ] + "status": { + "$ref": "#/components/schemas/PresenceStatus" }, - "email": { - "type": [ - "string", - "null" - ] - }, - "role": { - "type": "string" - }, - "invited_by_username": { - "type": [ - "string", - "null" - ] - }, - "invited_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { + "last_seen_at": { "type": [ "string", "null" @@ -31373,383 +32610,6 @@ } } }, - "ApiResponse_WorkspaceBillingCurrentResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "workspace_id", - "currency", - "monthly_quota", - "balance", - "total_spent", - "month_used", - "cycle_start_utc", - "cycle_end_utc", - "updated_at", - "created_at" - ], - "properties": { - "workspace_id": { - "type": "string", - "format": "uuid" - }, - "currency": { - "type": "string" - }, - "monthly_quota": { - "type": "number", - "format": "double" - }, - "balance": { - "type": "number", - "format": "double" - }, - "total_spent": { - "type": "number", - "format": "double" - }, - "month_used": { - "type": "number", - "format": "double" - }, - "cycle_start_utc": { - "type": "string", - "format": "date-time" - }, - "cycle_end_utc": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "ApiResponse_WorkspaceBillingHistoryResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "page", - "per_page", - "total", - "list" - ], - "properties": { - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceBillingHistoryItem" - } - } - } - } - } - }, - "ApiResponse_WorkspaceInfoResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "id", - "slug", - "name", - "plan", - "member_count", - "created_at", - "updated_at" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "slug": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "plan": { - "type": "string" - }, - "billing_email": { - "type": [ - "string", - "null" - ] - }, - "member_count": { - "type": "integer", - "format": "int64" - }, - "my_role": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - } - } - }, - "ApiResponse_WorkspaceListResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "workspaces", - "total" - ], - "properties": { - "workspaces": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceListItem" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - } - } - }, - "ApiResponse_WorkspaceMembersResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "members", - "total", - "page", - "per_page" - ], - "properties": { - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceMemberInfo" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - } - } - }, - "ApiResponse_WorkspaceProjectsResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "projects", - "total", - "page", - "per_page" - ], - "properties": { - "projects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceProjectItem" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - } - } - }, - "ApiResponse_WorkspaceStatsResponse": { - "type": "object", - "required": [ - "code", - "message" - ], - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "message": { - "type": "string" - }, - "data": { - "type": "object", - "required": [ - "project_count", - "member_count", - "recent_activities" - ], - "properties": { - "project_count": { - "type": "integer", - "format": "int64" - }, - "member_count": { - "type": "integer", - "format": "int64" - }, - "my_role": { - "type": [ - "string", - "null" - ] - }, - "recent_activities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceActivityItem" - } - } - } - } - } - }, "ApiResponse_bool": { "type": "object", "required": [ @@ -31992,13 +32852,78 @@ } } }, + "AvatarUploadResponse": { + "type": "object", + "required": [ + "avatar_url" + ], + "properties": { + "avatar_url": { + "type": "string" + } + } + }, + "BillingErrorItem": { + "type": "object", + "required": [ + "id", + "scope", + "scope_id", + "error_type", + "message", + "resolved", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "scope_id": { + "type": "string", + "format": "uuid" + }, + "error_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": {}, + "resolved": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BillingErrorsResponse": { + "type": "object", + "required": [ + "list" + ], + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BillingErrorItem" + } + } + } + }, "BillingRecord": { "type": "object", "required": [ "cost", "currency", "input_tokens", - "output_tokens" + "output_tokens", + "deducted_from" ], "properties": { "cost": { @@ -32015,6 +32940,9 @@ "output_tokens": { "type": "integer", "format": "int64" + }, + "deducted_from": { + "type": "string" } } }, @@ -33954,6 +34882,115 @@ } } }, + "ConversationResponse": { + "type": "object", + "required": [ + "id", + "user_id", + "scope", + "model", + "status", + "fork_count", + "is_shared", + "message_count", + "access_visibility", + "can_ask", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "user_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "model_config": {}, + "status": { + "type": "string" + }, + "root_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "fork_count": { + "type": "integer", + "format": "int32" + }, + "is_shared": { + "type": "boolean" + }, + "message_count": { + "type": "integer", + "format": "int32" + }, + "token_usage_total": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "access_visibility": { + "type": "string" + }, + "can_ask": { + "type": "string" + }, + "project_uid": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "model_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "model_name": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "CreateAccessKeyParams": { "type": "object", "required": [ @@ -34073,6 +35110,58 @@ } } }, + "CreateConversationParams": { + "type": "object", + "properties": { + "project_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_config": {}, + "access_visibility": { + "type": [ + "string", + "null" + ] + }, + "can_ask": { + "type": [ + "string", + "null" + ] + }, + "model_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid", + "description": "AI model UUID for model selection" + }, + "model_name": { + "type": [ + "string", + "null" + ], + "description": "AI model display name" + } + } + }, "CreateLabelParams": { "type": "object", "required": [ @@ -34109,6 +35198,44 @@ } } }, + "CreateMessageParams": { + "type": "object", + "required": [ + "content" + ], + "properties": { + "parent_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "content": { + "$ref": "#/components/schemas/MessageContent" + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "is_fork_origin": { + "type": [ + "boolean", + "null" + ] + }, + "metadata": {}, + "room_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + } + } + }, "CreateModelCapabilityRequest": { "type": "object", "required": [ @@ -34711,6 +35838,40 @@ } } }, + "ForkResponse": { + "type": "object", + "required": [ + "id", + "source_message_id", + "fork_message_id", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "conversation_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "source_message_id": { + "type": "string", + "format": "uuid" + }, + "fork_message_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, "GeneratePrDescriptionRequest": { "type": "object", "description": "Request body for generating a PR description.", @@ -34877,6 +36038,24 @@ "boolean", "null" ] + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "is_private": { + "type": [ + "boolean", + "null" + ] } } }, @@ -34983,6 +36162,26 @@ } } }, + "GroupedMemberListResponse": { + "type": "object", + "required": [ + "groups", + "total" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberGroup" + } + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "InvitationListResponse": { "type": "object", "required": [ @@ -35443,6 +36642,7 @@ "title", "state", "author", + "labels", "created_at", "updated_at", "created_by_ai" @@ -35488,6 +36688,12 @@ "null" ] }, + "labels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueLabelResponse" + } + }, "created_at": { "type": "string", "format": "date-time" @@ -35972,6 +37178,24 @@ } } }, + "MemberGroup": { + "type": "object", + "required": [ + "role", + "members" + ], + "properties": { + "role": { + "type": "string" + }, + "members": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MemberInfo" + } + } + } + }, "MemberInfo": { "type": "object", "required": [ @@ -36354,6 +37578,19 @@ } } }, + "MessageContent": { + "type": "object", + "required": [ + "role", + "content" + ], + "properties": { + "role": { + "type": "string" + }, + "content": {} + } + }, "MessageEditHistoryEntry": { "type": "object", "required": [ @@ -36398,6 +37635,102 @@ } } }, + "MessageResponse": { + "type": "object", + "required": [ + "id", + "conversation_id", + "role", + "content", + "is_fork_origin", + "version_number", + "is_latest", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "conversation_id": { + "type": "string", + "format": "uuid" + }, + "parent_message_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "role": { + "type": "string" + }, + "content": {}, + "model": { + "type": [ + "string", + "null" + ] + }, + "is_fork_origin": { + "type": "boolean" + }, + "stop_reason": { + "type": [ + "string", + "null" + ] + }, + "input_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "output_tokens": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "latency_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "metadata": {}, + "room_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "version_group_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "version_number": { + "type": "integer", + "format": "int32" + }, + "is_latest": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, "MessageSearchResponse": { "type": "object", "required": [ @@ -36772,49 +38105,6 @@ } } }, - "MyWorkspaceInvitation": { - "type": "object", - "description": "Invitation received by the current user (workspace invitation for self).", - "required": [ - "workspace_id", - "workspace_slug", - "workspace_name", - "role", - "invited_at" - ], - "properties": { - "workspace_id": { - "type": "string", - "format": "uuid" - }, - "workspace_slug": { - "type": "string" - }, - "workspace_name": { - "type": "string" - }, - "role": { - "type": "string" - }, - "invited_by_username": { - "type": [ - "string", - "null" - ] - }, - "invited_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - } - }, "NotificationListResponse": { "type": "object", "required": [ @@ -37111,83 +38401,26 @@ "room_created", "room_deleted", "system_announcement", - "project_invitation", - "workspace_invitation" + "project_invitation" ] }, "Pager": { "type": "object", "required": [ "page", - "par_page" + "per_page" ], "properties": { "page": { "type": "integer", "format": "int64" }, - "par_page": { + "per_page": { "type": "integer", "format": "int64" } } }, - "PendingInvitationInfo": { - "type": "object", - "required": [ - "user_id", - "username", - "role", - "invited_at" - ], - "properties": { - "user_id": { - "type": "string", - "format": "uuid" - }, - "username": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "email": { - "type": [ - "string", - "null" - ] - }, - "role": { - "type": "string" - }, - "invited_by_username": { - "type": [ - "string", - "null" - ] - }, - "invited_at": { - "type": "string", - "format": "date-time" - }, - "expires_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - } - }, "PrCommitResponse": { "type": "object", "required": [ @@ -37358,6 +38591,47 @@ } } }, + "PresenceChanged": { + "type": "object", + "description": "Presence changed event for broadcasting.", + "required": [ + "user_id", + "status" + ], + "properties": { + "user_id": { + "type": "string", + "format": "uuid" + }, + "project_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/PresenceStatus" + }, + "last_seen_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "PresenceStatus": { + "type": "string", + "description": "User presence status enum.", + "enum": [ + "online", + "idle", + "dnd", + "offline" + ] + }, "ProcessJoinRequest": { "type": "object", "required": [ @@ -37441,6 +38715,7 @@ "required": [ "project_uid", "currency", + "is_pro", "monthly_quota", "balance", "month_used", @@ -37457,6 +38732,9 @@ "currency": { "type": "string" }, + "is_pro": { + "type": "boolean" + }, "monthly_quota": { "type": "number", "format": "double" @@ -37772,6 +39050,12 @@ "name": { "type": "string" }, + "display_name": { + "type": [ + "string", + "null" + ] + }, "description": { "type": [ "string", @@ -37780,13 +39064,6 @@ }, "is_public": { "type": "boolean" - }, - "workspace_slug": { - "type": [ - "string", - "null" - ], - "description": "Optional workspace slug to associate this project with." } } }, @@ -37842,13 +39119,6 @@ "is_public": { "type": "boolean" }, - "workspace_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, "created_by": { "type": "string", "format": "uuid" @@ -38349,6 +39619,33 @@ } } }, + "ReactionGroupResponse": { + "type": "object", + "required": [ + "emoji", + "count", + "reacted_by_me", + "users" + ], + "properties": { + "emoji": { + "type": "string" + }, + "count": { + "type": "integer", + "format": "int32" + }, + "reacted_by_me": { + "type": "boolean" + }, + "users": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "ReactionListResponse": { "type": "object", "required": [ @@ -38760,6 +40057,22 @@ "null" ], "description": "If true, only return inline comments (those with a `path` set).\nIf false, only return general comments (no path).\nOmit to return all comments." + }, + "limit": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "offset": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 } } }, @@ -39140,6 +40453,61 @@ } } }, + "RolePriorityInfo": { + "type": "object", + "required": [ + "id", + "role_key", + "display_name", + "priority", + "created_at", + "updated_at" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "role_key": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "color": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "RolePriorityListResponse": { + "type": "object", + "required": [ + "roles" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RolePriorityInfo" + } + } + } + }, "RoomAccessGrantRequest": { "type": "object", "description": "Grant access to a private room.", @@ -39616,6 +40984,12 @@ "type": "string", "format": "uuid" } + }, + "reactions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReactionGroupResponse" + } } } }, @@ -40333,6 +41707,34 @@ } } }, + "ShareResponse": { + "type": "object", + "required": [ + "id", + "share_token", + "view_count" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "share_token": { + "type": "string" + }, + "view_count": { + "type": "integer", + "format": "int32" + }, + "expires_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, "SideBySideChangeTypeResponse": { "type": "string", "enum": [ @@ -41320,6 +42722,55 @@ } } }, + "UpdateConversationParams": { + "type": "object", + "properties": { + "title": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_config": {}, + "status": { + "type": [ + "string", + "null" + ] + }, + "access_visibility": { + "type": [ + "string", + "null" + ] + }, + "can_ask": { + "type": [ + "string", + "null" + ] + }, + "model_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "model_name": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateJoinSettingsRequest": { "type": "object", "required": [ @@ -41605,22 +43056,6 @@ } } }, - "UpdateRoleParams": { - "type": "object", - "required": [ - "user_id", - "role" - ], - "properties": { - "user_id": { - "type": "string", - "format": "uuid" - }, - "role": { - "type": "string" - } - } - }, "UpdateSkillRequest": { "type": "object", "properties": { @@ -41714,6 +43149,32 @@ } } }, + "UpsertRolePriorityRequest": { + "type": "object", + "required": [ + "role_key", + "display_name", + "priority" + ], + "properties": { + "role_key": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "priority": { + "type": "integer", + "format": "int32" + }, + "color": { + "type": [ + "string", + "null" + ] + } + } + }, "UserActivityItem": { "type": "object", "required": [ @@ -41788,6 +43249,214 @@ } } }, + "UserBillingErrorItem": { + "type": "object", + "required": [ + "id", + "scope", + "scope_id", + "error_type", + "message", + "resolved", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "scope": { + "type": "string" + }, + "scope_id": { + "type": "string", + "format": "uuid" + }, + "error_type": { + "type": "string" + }, + "message": { + "type": "string" + }, + "details": {}, + "resolved": { + "type": "boolean" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UserBillingErrorsResponse": { + "type": "object", + "required": [ + "list" + ], + "properties": { + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserBillingErrorItem" + } + } + } + }, + "UserBillingHistoryItem": { + "type": "object", + "required": [ + "uid", + "project_uid", + "amount", + "currency", + "reason", + "created_at" + ], + "properties": { + "uid": { + "type": "string", + "format": "uuid" + }, + "project_uid": { + "type": "string", + "format": "uuid" + }, + "user_uid": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "amount": { + "type": "number", + "format": "double" + }, + "currency": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "extra": {}, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "UserBillingHistoryQuery": { + "type": "object", + "properties": { + "page": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + }, + "per_page": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, + "UserBillingHistoryResponse": { + "type": "object", + "required": [ + "page", + "per_page", + "total", + "list" + ], + "properties": { + "page": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "per_page": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "total": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "list": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserBillingHistoryItem" + } + } + } + }, + "UserBillingResponse": { + "type": "object", + "required": [ + "user_uid", + "balance", + "currency", + "is_pro", + "monthly_quota", + "month_used", + "updated_at", + "created_at" + ], + "properties": { + "user_uid": { + "type": "string", + "format": "uuid" + }, + "balance": { + "type": "number", + "format": "double" + }, + "currency": { + "type": "string" + }, + "is_pro": { + "type": "boolean" + }, + "monthly_quota": { + "type": "number", + "format": "double" + }, + "month_used": { + "type": "number", + "format": "double" + }, + "cycle_start_utc": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "cycle_end_utc": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, "UserCard": { "type": "object", "required": [ @@ -42026,6 +43695,7 @@ "required": [ "uid", "repo_name", + "project_name", "default_branch", "is_private", "storage_path", @@ -42040,6 +43710,9 @@ "repo_name": { "type": "string" }, + "project_name": { + "type": "string" + }, "description": { "type": [ "string", @@ -42176,6 +43849,60 @@ } } }, + "UserSummaryResponse": { + "type": "object", + "required": [ + "info", + "repos", + "projects", + "activity", + "heatmap", + "follower_count", + "following_count", + "stars_count" + ], + "properties": { + "info": { + "$ref": "#/components/schemas/UserInfoExternal" + }, + "repos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserRepoInfo" + } + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserProjectInfo" + } + }, + "activity": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserActivityItem" + } + }, + "heatmap": { + "$ref": "#/components/schemas/ContributionHeatmapResponse" + }, + "follower_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "following_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "stars_count": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, "Verify2FAParams": { "type": "object", "required": [ @@ -42326,644 +44053,6 @@ "format": "int64" } } - }, - "WorkspaceAcceptBySlugParams": { - "type": "object", - "description": "Request body for accepting workspace invitation by slug.", - "required": [ - "slug" - ], - "properties": { - "slug": { - "type": "string" - } - } - }, - "WorkspaceActivityItem": { - "type": "object", - "required": [ - "id", - "project_name", - "event_type", - "title", - "actor_name", - "created_at" - ], - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "project_name": { - "type": "string" - }, - "event_type": { - "type": "string" - }, - "title": { - "type": "string" - }, - "content": { - "type": [ - "string", - "null" - ] - }, - "actor_name": { - "type": "string" - }, - "actor_avatar": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceBillingAddCreditParams": { - "type": "object", - "required": [ - "amount" - ], - "properties": { - "amount": { - "type": "number", - "format": "double" - }, - "reason": { - "type": [ - "string", - "null" - ] - } - } - }, - "WorkspaceBillingCurrentResponse": { - "type": "object", - "required": [ - "workspace_id", - "currency", - "monthly_quota", - "balance", - "total_spent", - "month_used", - "cycle_start_utc", - "cycle_end_utc", - "updated_at", - "created_at" - ], - "properties": { - "workspace_id": { - "type": "string", - "format": "uuid" - }, - "currency": { - "type": "string" - }, - "monthly_quota": { - "type": "number", - "format": "double" - }, - "balance": { - "type": "number", - "format": "double" - }, - "total_spent": { - "type": "number", - "format": "double" - }, - "month_used": { - "type": "number", - "format": "double" - }, - "cycle_start_utc": { - "type": "string", - "format": "date-time" - }, - "cycle_end_utc": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceBillingHistoryItem": { - "type": "object", - "required": [ - "uid", - "workspace_id", - "amount", - "currency", - "reason", - "created_at" - ], - "properties": { - "uid": { - "type": "string", - "format": "uuid" - }, - "workspace_id": { - "type": "string", - "format": "uuid" - }, - "user_id": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "amount": { - "type": "number", - "format": "double" - }, - "currency": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "extra": {}, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceBillingHistoryQuery": { - "type": "object", - "properties": { - "page": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - } - } - }, - "WorkspaceBillingHistoryResponse": { - "type": "object", - "required": [ - "page", - "per_page", - "total", - "list" - ], - "properties": { - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "list": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceBillingHistoryItem" - } - } - } - }, - "WorkspaceInfoResponse": { - "type": "object", - "required": [ - "id", - "slug", - "name", - "plan", - "member_count", - "created_at", - "updated_at" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "slug": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "plan": { - "type": "string" - }, - "billing_email": { - "type": [ - "string", - "null" - ] - }, - "member_count": { - "type": "integer", - "format": "int64" - }, - "my_role": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceInitParams": { - "type": "object", - "required": [ - "slug", - "name" - ], - "properties": { - "slug": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - } - } - }, - "WorkspaceInviteAcceptParams": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string" - } - } - }, - "WorkspaceInviteParams": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string" - }, - "role": { - "type": [ - "string", - "null" - ] - } - } - }, - "WorkspaceListItem": { - "type": "object", - "required": [ - "id", - "slug", - "name", - "plan", - "my_role", - "created_at" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "slug": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "plan": { - "type": "string" - }, - "my_role": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceListResponse": { - "type": "object", - "required": [ - "workspaces", - "total" - ], - "properties": { - "workspaces": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceListItem" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "WorkspaceMemberInfo": { - "type": "object", - "required": [ - "user_id", - "username", - "role", - "joined_at" - ], - "properties": { - "user_id": { - "type": "string", - "format": "uuid" - }, - "username": { - "type": "string" - }, - "display_name": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "role": { - "type": "string" - }, - "joined_at": { - "type": "string", - "format": "date-time" - }, - "invited_by_username": { - "type": [ - "string", - "null" - ], - "description": "Username of the person who invited this member." - } - } - }, - "WorkspaceMembersResponse": { - "type": "object", - "required": [ - "members", - "total", - "page", - "per_page" - ], - "properties": { - "members": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceMemberInfo" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "WorkspaceProjectItem": { - "type": "object", - "required": [ - "uid", - "name", - "display_name", - "is_public", - "created_at", - "updated_at" - ], - "properties": { - "uid": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "display_name": { - "type": "string" - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "is_public": { - "type": "boolean" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - } - } - }, - "WorkspaceProjectsQuery": { - "type": "object", - "properties": { - "page": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 - } - } - }, - "WorkspaceProjectsResponse": { - "type": "object", - "required": [ - "projects", - "total", - "page", - "per_page" - ], - "properties": { - "projects": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceProjectItem" - } - }, - "total": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "page": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "per_page": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "WorkspaceStatsResponse": { - "type": "object", - "required": [ - "project_count", - "member_count", - "recent_activities" - ], - "properties": { - "project_count": { - "type": "integer", - "format": "int64" - }, - "member_count": { - "type": "integer", - "format": "int64" - }, - "my_role": { - "type": [ - "string", - "null" - ] - }, - "recent_activities": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkspaceActivityItem" - } - } - } - }, - "WorkspaceUpdateParams": { - "type": "object", - "properties": { - "name": { - "type": [ - "string", - "null" - ] - }, - "description": { - "type": [ - "string", - "null" - ] - }, - "avatar_url": { - "type": [ - "string", - "null" - ] - }, - "billing_email": { - "type": [ - "string", - "null" - ] - } - } } } }, @@ -42996,6 +44085,10 @@ "name": "Room", "description": "Real-time chat rooms" }, + { + "name": "Presence", + "description": "User presence and online status" + }, { "name": "Search", "description": "Global and room message search" @@ -43005,8 +44098,8 @@ "description": "User profiles and settings" }, { - "name": "Workspace", - "description": "Workspace management and collaboration" + "name": "AI Chat", + "description": "AI conversation and messaging" } ] } \ No newline at end of file diff --git a/package.json b/package.json index 591eed4..0780b7f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "node script/build.js", "lint": "eslint .", "format": "prettier --write \"**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", @@ -15,8 +15,9 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@fontsource-variable/geist": "^5.2.8", + "@lobehub/icons": "^5.8.0", "@reduxjs/toolkit": "^2.11.2", - "@tailwindcss/vite": "^4.2.1", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-form": "^1.29.1", "@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-pacer": "^0.22.0", @@ -24,13 +25,15 @@ "@tanstack/react-router": "^1.169.1", "@tanstack/react-store": "^0.11.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "alova": "^3.5.1", "axios": "^1.15.2", - "casl": "^1.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dayjs": "^1.11.20", + "dexie": "^4.4.2", + "dexie-react-hooks": "^4.4.0", "dnd-kit": "^0.0.2", "embla-carousel-react": "^8.6.0", "idb": "^8.0.3", @@ -46,10 +49,13 @@ "react-dom": "^19.2.4", "react-hook-form": "^7.75.0", "react-i18next": "^17.0.6", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-resizable-panels": "^4.10.0", "react-router-dom": "^7.14.2", + "react-virtuoso": "^4.18.6", "recharts": "^3.8.1", + "remark-gfm": "^4.0.1", "shadcn": "^4.6.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", @@ -64,6 +70,8 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@tailwindcss/postcss": "^4.2.4", + "@tailwindcss/vite": "^4.2.4", "@types/node": "^24.12.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/push.sh b/push.sh new file mode 100644 index 0000000..1718ba0 --- /dev/null +++ b/push.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +REGISTRY="${REGISTRY:-harbor.gitdata.me/gtateam}" +TAG="${TAG:-$(git rev-parse --short HEAD)}" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[OK]${NC} $*"; } +err() { echo -e "${RED}[ERR]${NC} $*"; exit 1; } + +# ── credentials ───────────────────────────────────────────────────── +: "${DOCKER_USERNAME:?DOCKER_USERNAME env var required}" +: "${DOCKER_PASSWORD:?DOCKER_PASSWORD env var required}" + +log "Logging into $REGISTRY..." +echo "$DOCKER_PASSWORD" | docker login "$REGISTRY" -u "$DOCKER_USERNAME" --password-stdin + +# ── tag & push ────────────────────────────────────────────────────── +IMAGES=(app email-worker git-hook gitserver metrics-aggregator static-server gingress) + +for name in "${IMAGES[@]}"; do + SRC="${name}:${TAG}" + DST="${REGISTRY}/${name}:${TAG}" + + log "Tagging $SRC -> $DST" + docker tag "$SRC" "$DST" + + log "Pushing $DST" + docker push "$DST" +done + +log "All images pushed to $REGISTRY" diff --git a/src/App.tsx b/src/App.tsx index 444cc34..494f7fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import {BrowserRouter, Navigate, Route, Routes} from "react-router-dom"; -import {Provider} from "react-redux"; -import {store} from "@/store"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { Provider } from "react-redux"; +import { store } from "@/store"; import { ChangePasswordPage, ForgotPasswordPage, @@ -8,43 +8,145 @@ import { RegisterPage, ResetPasswordPage, TwoFactorPage, + VerifyEmailPage, } from "@/app/auth"; -import {RootLayout} from "@/app/layout"; -import {MePage} from "@/app/me"; -import {ChannelLayout} from "@/app/channel"; -import {RedirectIfAuth, RequireAuth} from "@/components/auth"; +import { RootLayout } from "@/app/layout"; +import { MePage, MeLayout } from "@/app/me"; +import { ChannelLayout } from "@/app/channel"; +import { + ProjectLayout, + ReposPage, + IssuesPage, + NewIssuePage, + SkillsPage, + BoardPage, + ChannelPage, + RepoDetailPage, + CommitDetailPage, + IssueDetailPage, + SkillDetailPage, + PullRequestDetailPage, + ProjectSettingsLayout, + GeneralSettings, + MembersSettings, + AccessSettings, + LabelsSettings, + BillingSettings, +} from "@/app/project"; +import { ChatPage } from "@/app/chat"; +import CodePage from "@/app/project/repo/code"; +import CommitsPage from "@/app/project/repo/commits"; +import PullsPage from "@/app/project/repo/pulls"; +import BranchesPage from "@/app/project/repo/branches"; +import TagsPage from "@/app/project/repo/tags"; +import RepoSettingsLayout from "@/app/project/repo/settings/RepoSettingsLayout"; +import RepoGeneralSettings from "@/app/project/repo/settings/GeneralSettings"; +import BranchProtectionSettings from "@/app/project/repo/settings/BranchProtectionSettings"; +import TreePage from "@/app/project/repo/tree"; +import { RedirectIfAuth, RequireAuth } from "@/components/auth"; +import { + SettingsLayout, + MyAccountPage, + BillingPage, + AppearancePage, + NotificationsPage, + PasswordPage, + EmailPage, + SshKeysPage, + AccessKeysPage, + PushSettingsPage, +} from "@/app/settings"; export default function App() { - return ( - - - - }> - }/> - }/> - }/> - }/> - }/> - - }/> + return ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> - }> - }> - }> - }/> - }/> - }/> - }/> - }/> - }/> - - - + }> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + - }/> - }/> - - - - ); -} \ No newline at end of file + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + }> + {/* Channel-based routes if any */} + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + }> + }> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + + + + + + + } /> + } /> + + + + ); +} diff --git a/src/app/auth/change-password.tsx b/src/app/auth/change-password.tsx index ac641f0..090437f 100644 --- a/src/app/auth/change-password.tsx +++ b/src/app/auth/change-password.tsx @@ -5,9 +5,10 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { apiUserChangePassword } from "@/client/api"; +import { apiAuthCaptcha, apiUserChangePassword } from "@/client/api"; import type { ChangePasswordParams } from "@/client/model"; import { getCaptcha, encryptPassword } from "@/lib/auth-crypto"; +import { AUTH_FORM } from "@/css/auth/styles"; export function ChangePasswordPage() { const navigate = useNavigate(); @@ -22,7 +23,7 @@ export function ChangePasswordPage() { const loadCaptcha = async () => { try { - const result = await getCaptcha(true); + const result = await getCaptcha(apiAuthCaptcha, true); setCaptchaImage(result.base64); setPublicKey(result.publicKey || ""); } catch (err) { @@ -53,7 +54,7 @@ export function ChangePasswordPage() { new_password: encryptedNewPassword, }); - navigate("/settings"); + navigate("/me/settings"); } catch (err: any) { if (err.response?.status === 401) { setError("Current password is incorrect"); @@ -67,24 +68,24 @@ export function ChangePasswordPage() { }; return ( -
-
-
-
-

Change password

-

Update your account password

+
+
+
+
+

Change password

+

Update your account password

-
+ {error && ( - + {error} )} -
-