This commit is contained in:
ZhenYi 2026-04-15 09:08:09 +08:00
commit 93cfff9738
1060 changed files with 211298 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
target/
.git/
.idea/
.vscode/
node_modules/
*.log
.env
.env.local
.env.*.local

128
.drone.yml Normal file
View File

@ -0,0 +1,128 @@
---
# Drone CI Pipeline
kind: pipeline
type: kubernetes
name: default
clone:
disable: true
trigger:
event:
- push
- tag
branch:
- main
environment:
REGISTRY: harbor.gitdata.me/gta_team
CARGO_TERM_COLOR: always
BUILD_TARGET: x86_64-unknown-linux-gnu
steps:
- name: clone
image: bitnami/git:latest
commands:
- |
if [ -n "${DRONE_TAG}" ]; then
git checkout ${DRONE_TAG}
fi
- name: frontend-deps
image: node:22-alpine
commands:
- cd apps/frontend && corepack enable && corepack prepare pnpm@10 --activate && pnpm install --frozen-lockfile
- name: frontend-build
image: node:22-alpine
commands:
- cd apps/frontend && pnpm build
- name: docker-build
image: gcr.io/kaniko-project/executor:latest
environment:
DOCKER_CONFIG:
from_secret: kaniko_secret
commands:
- |
TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
echo "==> Building images with tag: ${TAG}"
/kaniko/executor --context . --dockerfile docker/app.Dockerfile --destination ${REGISTRY}/app:${TAG} --destination ${REGISTRY}/app:latest
/kaniko/executor --context . --dockerfile docker/gitserver.Dockerfile --destination ${REGISTRY}/gitserver:${TAG} --destination ${REGISTRY}/gitserver:latest
/kaniko/executor --context . --dockerfile docker/email-worker.Dockerfile --destination ${REGISTRY}/email-worker:${TAG} --destination ${REGISTRY}/email-worker:latest
/kaniko/executor --context . --dockerfile docker/git-hook.Dockerfile --destination ${REGISTRY}/git-hook:${TAG} --destination ${REGISTRY}/git-hook:latest
/kaniko/executor --context . --dockerfile docker/migrate.Dockerfile --destination ${REGISTRY}/migrate:${TAG} --destination ${REGISTRY}/migrate:latest
/kaniko/executor --context . --dockerfile docker/operator.Dockerfile --destination ${REGISTRY}/operator:${TAG} --destination ${REGISTRY}/operator:latest
/kaniko/executor --context . --dockerfile docker/static.Dockerfile --destination ${REGISTRY}/static:${TAG} --destination ${REGISTRY}/static:latest
/kaniko/executor --context . --dockerfile docker/frontend.Dockerfile --destination ${REGISTRY}/frontend:${TAG} --destination ${REGISTRY}/frontend:latest
echo "==> All images pushed"
depends_on: [ frontend-build ]
- name: prepare-kubeconfig
image: alpine:latest
commands:
- apk add --no-cache kubectl
- mkdir -p ~/.kube
- echo "${KUBECONFIG}" | base64 -d > ~/.kube/config
- chmod 600 ~/.kube/config
- name: helm-deploy
image: alpine/helm:latest
commands:
- apk add --no-cache curl kubectl
- curl -fsSL -o /tmp/helm.tar.gz https://get.helm.sh/helm-v3.15.0-linux-amd64.tar.gz
- tar -xzf /tmp/helm.tar.gz -C /tmp
- mv /tmp/linux-amd64/helm /usr/local/bin/helm && chmod +x /usr/local/bin/helm
- |
TAG="${DRONE_TAG:-${DRONE_COMMIT_SHA:0:8}}"
helm upgrade --install gitdata deploy/ \
--namespace gitdataai \
-f deploy/values.yaml \
-f deploy/secrets.yaml \
--set image.registry=${REGISTRY} \
--set app.image.tag=${TAG} \
--set gitserver.image.tag=${TAG} \
--set emailWorker.image.tag=${TAG} \
--set gitHook.image.tag=${TAG} \
--set operator.image.tag=${TAG} \
--set static.image.tag=${TAG} \
--set frontend.image.tag=${TAG} \
--wait \
--timeout 5m \
--atomic
depends_on: [ docker-build, prepare-kubeconfig ]
when:
branch: [ main ]
- name: verify-rollout
image: bitnami/kubectl:latest
commands:
- kubectl rollout status deployment/gitdata-frontend -n gitdataai --timeout=300s
- kubectl rollout status deployment/gitdata-app -n gitdataai --timeout=300s
- kubectl rollout status deployment/gitdata-gitserver -n gitdataai --timeout=300s
- kubectl rollout status deployment/gitdata-email-worker -n gitdataai --timeout=300s
- kubectl rollout status deployment/gitdata-git-hook -n gitdataai --timeout=300s
depends_on: [ helm-deploy ]
when:
branch: [ main ]
# =============================================================================
# Secrets (register via drone CLI)
# =============================================================================
# Harbor username
# drone secret add --repository <org/repo> --name drone_secret_docker_username --data <username>
#
# Harbor password
# drone secret add --repository <org/repo> --name drone_secret_docker_password --data <password>
#
# kubeconfig (base64)
# drone secret add --repository <org/repo> --name kubeconfig --data "$(cat ~/.kube/config | base64 -w 0)"
#
# Kaniko dockerconfigjson (for private registry)
# drone secret add --repository <org/repo> --name kaniko_secret --data "$(cat ~/.docker/config.json | base64 -w 0)"
#
# Local exec:
# drone exec --trusted \
# --secret=DRONE_SECRET_DOCKER_USERNAME=<username> \
# --secret=DRONE_SECRET_DOCKER_PASSWORD=<password> \
# --secret=KUBECONFIG=$(base64 -w 0 ~/.kube/config)

109
.env.example Normal file
View File

@ -0,0 +1,109 @@
# =============================================================================
# Required - 程序启动必须配置
# =============================================================================
# 数据库连接
APP_DATABASE_URL=postgresql://user:password@localhost:5432/dbname
APP_DATABASE_SCHEMA_SEARCH_PATH=public
# Redis支持多节点逗号分隔
APP_REDIS_URL=redis://localhost:6379
# APP_REDIS_URLS=redis://localhost:6379,redis://localhost:6378
# AI 服务
APP_AI_BASIC_URL=https://api.openai.com/v1
APP_AI_API_KEY=sk-xxxxx
# Embedding + 向量检索
APP_EMBED_MODEL_BASE_URL=https://api.openai.com/v1
APP_EMBED_MODEL_API_KEY=sk-xxxxx
APP_EMBED_MODEL_NAME=text-embedding-3-small
APP_EMBED_MODEL_DIMENSIONS=1536
APP_QDRANT_URL=http://localhost:6333
# APP_QDRANT_API_KEY=
# SMTP 邮件
APP_SMTP_HOST=smtp.example.com
APP_SMTP_PORT=587
APP_SMTP_USERNAME=noreply@example.com
APP_SMTP_PASSWORD=xxxxx
APP_SMTP_FROM=noreply@example.com
APP_SMTP_TLS=true
APP_SMTP_TIMEOUT=30
# 文件存储
APP_AVATAR_PATH=/data/avatars
# Git 仓库存储根目录
APP_REPOS_ROOT=/data/repos
# =============================================================================
# Domain / URL可选有默认值
# =============================================================================
APP_DOMAIN_URL=http://127.0.0.1
# APP_STATIC_DOMAIN=
# APP_MEDIA_DOMAIN=
# APP_GIT_HTTP_DOMAIN=
# =============================================================================
# Database Pool可选有默认值
# =============================================================================
# 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_REPLICAS=
# APP_DATABASE_HEALTH_CHECK_INTERVAL=30
# APP_DATABASE_RETRY_ATTEMPTS=3
# APP_DATABASE_RETRY_DELAY=5
# =============================================================================
# Redis Pool可选有默认值
# =============================================================================
# APP_REDIS_POOL_SIZE=10
# APP_REDIS_CONNECT_TIMEOUT=5
# APP_REDIS_ACQUIRE_TIMEOUT=5
# =============================================================================
# SSH可选有默认值
# =============================================================================
# APP_SSH_DOMAIN=
# APP_SSH_PORT=22
# APP_SSH_SERVER_PRIVATE_KEY=
# APP_SSH_SERVER_PUBLIC_KEY=
# =============================================================================
# Logging可选有默认值
# =============================================================================
# APP_LOG_LEVEL=info
# APP_LOG_FORMAT=json
# APP_LOG_FILE_ENABLED=false
# APP_LOG_FILE_PATH=./logs
# APP_LOG_FILE_ROTATION=daily
# APP_LOG_FILE_MAX_FILES=7
# APP_LOG_FILE_MAX_SIZE=104857600
# OpenTelemetry可选默认关闭
# APP_OTEL_ENABLED=false
# APP_OTEL_ENDPOINT=http://localhost:5080/api/default/v1/traces
# APP_OTEL_SERVICE_NAME=
# APP_OTEL_SERVICE_VERSION=
# APP_OTEL_AUTHORIZATION=
# APP_OTEL_ORGANIZATION=
# =============================================================================
# NATS / Hook Pool可选有默认值
# =============================================================================
# HOOK_POOL_MAX_CONCURRENT=CPU 核数)
# HOOK_POOL_CPU_THRESHOLD=80.0
# HOOK_POOL_REDIS_LIST_PREFIX={hook}
# HOOK_POOL_REDIS_LOG_CHANNEL=hook:logs
# HOOK_POOL_REDIS_BLOCK_TIMEOUT=5
# HOOK_POOL_REDIS_MAX_RETRIES=3
# HOOK_POOL_WORKER_ID=(随机 UUID

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
/target
node_modules
.claude
.zed
.vscode
.idea
.env
.env.local
dist
deploy/secrets.yaml
.codex
.qwen
.opencode
.omc
AGENT.md
ARCHITECTURE.md
.agents
.agents.md

9032
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

186
Cargo.toml Normal file
View File

@ -0,0 +1,186 @@
[workspace]
members = [
"libs/models",
"libs/session",
"libs/git",
"libs/email",
"libs/queue",
"libs/room",
"libs/config",
"libs/service",
"libs/db",
"libs/api",
"libs/webhook",
"libs/transport",
"libs/rpc",
"libs/avatar",
"libs/agent",
"libs/migrate",
"libs/agent-tool-derive",
"apps/migrate",
"apps/app",
"apps/git-hook",
"apps/gitserver",
"apps/email",
"apps/operator",
"apps/static",
]
resolver = "3"
[workspace.dependencies]
models = { path = "libs/models" }
session = { path = "libs/session" }
git = { path = "libs/git" }
email = { path = "libs/email" }
queue = { path = "libs/queue" }
room = { path = "libs/room" }
config = { path = "libs/config" }
service = { path = "libs/service" }
db = { path = "libs/db" }
api = { path = "libs/api" }
agent = { path = "libs/agent" }
webhook = { path = "libs/webhook" }
rpc = { path = "libs/rpc" }
avatar = { path = "libs/avatar" }
migrate = { path = "libs/migrate" }
sea-query = "1.0.0-rc.31"
actix-web = "4.13.0"
actix-files = "0.6.10"
actix-cors = "0.7.1"
actix-session = "0.11.0"
actix-ws = "0.4.0"
actix-multipart = "0.7.2"
actix-analytics = "1.2.1"
actix-jwt-session = "1.0.7"
actix-csrf = "0.8.0"
actix-rt = "2.11.0"
actix = "0.13"
async-stream = "0.3"
async-nats = "0.47.0"
actix-service = "2.0.3"
actix-utils = "3.0.1"
redis = "1.1.0"
anyhow = "1.0.102"
derive_more = "2.1.1"
blake3 = "1.8.3"
argon2 = "0.5.3"
thiserror = "2.0.18"
password-hash = "0.6.0"
awc = "3.8.2"
bstr = "1.12.1"
captcha-rs = "0.5.0"
deadpool-redis = "0.23.0"
deadpool = "0.13.0"
dotenv = "0.15.0"
env_logger = "0.11.10"
flate2 = "1.1.9"
git2 = "0.20.4"
slog = "2.8.2"
git2-ext = "1.0.0"
git2-hooks = "0.7.0"
futures = "0.3.32"
futures-util = "0.3.32"
globset = "0.4.18"
hex = "0.4.3"
lettre = { version = "0.11.19", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "pool"] }
kube = { version = "0.98", features = ["derive", "runtime"] }
k8s-openapi = { version = "0.24", default-features = false, features = ["v1_28", "schemars"] }
mime = "0.3.17"
mime_guess2 = "2.3.1"
opentelemetry = "0.31.0"
opentelemetry-otlp = "0.31.0"
opentelemetry_sdk = "0.31.0"
opentelemetry-http = "0.31.0"
prost = "0.14.3"
prost-build = "0.14.3"
qdrant-client = "1.17.0"
rand = "0.10.0"
russh = { version = "0.55.0", default-features = false }
hmac = { version = "0.12.1", features = ["std"] }
sha1_smol = "1.0.1"
rsa = { version = "0.9.7", package = "rsa" }
reqwest = { version = "0.13.2", default-features = false }
dotenvy = "0.15.7"
aws-sdk-s3 = "1.127.0"
sea-orm = "2.0.0-rc.37"
sea-orm-migration = "2.0.0-rc.37"
sha1 = { version = "0.10.6", features = ["compress"] }
sha2 = "0.11.0-rc.5"
sysinfo = "0.38.4"
ssh-key = "0.7.0-rc.9"
tar = "0.4.45"
zip = "8.3.1"
tokenizer = "0.1.2"
tiktoken-rs = "0.9.1"
regex = "1.12.3"
jsonwebtoken = "10.3.0"
once_cell = "1.21.4"
async-trait = "0.1.89"
fs2 = "0.4.3"
image = "0.25.10"
tokio = "1.50.0"
tokio-util = "0.7.18"
tokio-stream = "0.1.18"
url = "2.5.8"
num_cpus = "1.17.0"
clap = "4.6.0"
time = "0.3.47"
chrono = "0.4.44"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
tracing-opentelemetry = "0.32.1"
tonic = "0.14.5"
tonic-build = "0.14.5"
uuid = "1.22.0"
async-openai = { version = "0.34.0", features = ["embedding", "chat-completion"] }
hostname = "0.4"
utoipa = { version = "5.4.0", features = ["chrono", "uuid"] }
rust_decimal = "1.40.0"
walkdir = "2.5.0"
moka = "0.12.15"
serde = "1.0.228"
serde_json = "1.0.149"
serde_yaml = "0.9.33"
serde_bytes = "0.11.19"
base64 = "0.22.1"
[workspace.package]
version = "0.2.9"
edition = "2024"
authors = []
description = ""
repository = ""
readme = ""
homepage = ""
license = ""
keywords = []
categories = []
documentation = ""
[workspace.lints.rust]
unsafe_code = "warn"
[workspace.lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
[profile.dev]
debug = 1
incremental = true
codegen-units = 256
[profile.release]
lto = "thin"
codegen-units = 1
strip = true
opt-level = 3
[profile.dev.package.num-bigint-dig]
opt-level = 3

263
README.md Normal file
View File

@ -0,0 +1,263 @@
# Code API
> 一个现代化的代码协作与团队沟通平台,融合 GitHub 的代码管理体验与 Slack 的实时沟通功能。
## 项目概述
Code API 是一个全栈 monorepo 项目,采用 Rust 后端 + React 前端的技术栈。项目实现了类似 GitHub 的 Issue 追踪、Pull Request 代码审查、Git 仓库管理,以及类似 Slack 的实时聊天 Room 功能。
### 核心功能
- **代码仓库管理** — Git 仓库浏览、分支管理、文件操作
- **Issue 追踪** — 创建、分配、标签、评论 Issue
- **Pull Request** — 代码审查、Inline Comment、CI 状态检查
- **实时聊天 (Room)** — 团队频道、消息回复、Thread 讨论
- **通知系统** — 邮件通知、Webhook 集成
- **用户系统** — 认证、会话管理、权限控制
## 技术栈
### 后端 (Rust)
| 类别 | 技术 |
|------|------|
| 语言 | Rust 2024 Edition |
| Web 框架 | Actix-web |
| ORM | SeaORM |
| 数据库 | PostgreSQL |
| 缓存 | Redis |
| 实时通信 | WebSocket (actix-ws) |
| 消息队列 | NATS |
| 向量数据库 | Qdrant |
| Git 操作 | git2 / git2-ext |
| 认证 | JWT + Session |
| API 文档 | utoipa (OpenAPI) |
### 前端 (TypeScript/React)
| 类别 | 技术 |
|------|------|
| 语言 | TypeScript 5.9 |
| 框架 | React 19 |
| 路由 | React Router v7 |
| 构建工具 | Vite 8 + SWC |
| UI 组件 | shadcn/ui + Tailwind CSS 4 |
| 状态管理 | TanStack Query |
| HTTP 客户端 | Axios + OpenAPI 生成 |
| Markdown | react-markdown + Shiki |
| 拖拽 | dnd-kit |
## 项目结构
```
code/
├── apps/ # 应用程序入口
│ ├── app/ # 主 Web 应用
│ ├── gitserver/ # Git HTTP/SSH 服务器
│ ├── git-hook/ # Git Hook 处理服务
│ ├── email/ # 邮件发送服务
│ ├── migrate/ # 数据库迁移工具
│ └── operator/ # Kubernetes 操作器
├── libs/ # 共享库
│ ├── api/ # REST API 路由与处理器
│ ├── models/ # 数据库模型 (SeaORM)
│ ├── service/ # 业务逻辑层
│ ├── db/ # 数据库连接池
│ ├── config/ # 配置管理
│ ├── session/ # 会话管理
│ ├── git/ # Git 操作封装
│ ├── room/ # 实时聊天服务
│ ├── queue/ # 消息队列
│ ├── webhook/ # Webhook 处理
│ ├── rpc/ # RPC 服务 (gRPC/Tonic)
│ ├── email/ # 邮件发送
│ ├── agent/ # AI Agent 集成
│ ├── avatar/ # 头像处理
│ ├── transport/ # 传输层
│ └── migrate/ # 迁移脚本
├── src/ # 前端源代码
│ ├── app/ # 页面路由组件
│ ├── components/ # 可复用组件
│ ├── contexts/ # React Context
│ ├── client/ # API 客户端 (OpenAPI 生成)
│ ├── hooks/ # 自定义 Hooks
│ └── lib/ # 工具函数
├── docker/ # Docker 配置
├── scripts/ # 构建脚本
├── openapi.json # OpenAPI 规范文件
└── Cargo.toml # Rust Workspace 配置
```
## 快速开始
### 环境要求
- **Rust**: 最新稳定版 (Edition 2024)
- **Node.js**: >= 20
- **pnpm**: >= 10
- **PostgreSQL**: >= 14
- **Redis**: >= 6
### 安装步骤
1. **克隆仓库**
```bash
git clone <repository-url>
cd code
```
2. **配置环境变量**
```bash
cp .env.example .env
# 编辑 .env 文件,配置数据库连接等信息
```
3. **启动数据库与 Redis**
```bash
# 使用 Docker 启动(推荐)
docker compose -f docker/docker-compose.yml up -d
```
4. **数据库迁移**
```bash
cargo run -p migrate
```
5. **启动后端服务**
```bash
cargo run -p app
```
6. **启动前端开发服务器**
```bash
pnpm install
pnpm dev
```
7. **访问应用**
- 前端: http://localhost:5173
- 后端 API: http://localhost:8080
## 开发指南
### 后端开发
```bash
# 运行所有测试
cargo test
# 运行特定模块测试
cargo test -p service
# 检查代码质量
cargo clippy --workspace
# 格式化代码
cargo fmt --workspace
# 生成 OpenAPI 文档
pnpm openapi:gen-json
```
### 前端开发
```bash
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
# 代码检查
pnpm lint
# 生成 OpenAPI 客户端
pnpm openapi:gen
```
### 数据库迁移
```bash
# 创建新迁移
cd libs/migrate && cargo run -- create <migration_name>
# 执行迁移
cargo run -p migrate
```
## 配置说明
### 必需配置项
| 变量名 | 说明 | 示例 |
|--------|------|------|
| `APP_DATABASE_URL` | PostgreSQL 连接 | `postgresql://user:pass@localhost/db` |
| `APP_REDIS_URL` | Redis 连接 | `redis://localhost:6379` |
| `APP_AI_API_KEY` | AI 服务 API Key | `sk-xxxxx` |
| `APP_SMTP_*` | SMTP 邮件配置 | 见 `.env.example` |
### 可选配置项
| 变量名 | 默认值 | 说明 |
|--------|--------|------|
| `APP_DATABASE_MAX_CONNECTIONS` | 10 | 数据库连接池大小 |
| `APP_LOG_LEVEL` | info | 日志级别 |
| `APP_QDRANT_URL` | - | 向量数据库地址 |
| `APP_REPOS_ROOT` | /data/repos | Git 仓库存储路径 |
完整配置请参考 `.env.example`
## API 文档
启动服务后访问 http://localhost:8080/swagger-ui 查看完整的 API 文档。
## 架构设计
### 后端分层架构
```
┌─────────────────────────────────────┐
│ apps/app │ ← 应用入口
├─────────────────────────────────────┤
│ libs/api │ ← HTTP 路由/Handler
├─────────────────────────────────────┤
│ libs/service │ ← 业务逻辑层
├─────────────────────────────────────┤
│ libs/models │ libs/db │ libs/git│ ← 数据访问层
├─────────────────────────────────────┤
│ PostgreSQL │ Redis │ Qdrant │ ← 存储层
└─────────────────────────────────────┘
```
### 前端目录结构
```
src/
├── app/ # 页面级组件 (按功能模块组织)
│ ├── project/ # 项目相关页面 (Issue、Settings)
│ ├── repository/ # 仓库相关页面 (PR、代码浏览)
│ └── settings/ # 用户设置
├── components/ # 可复用组件
│ ├── ui/ # 基础 UI 组件 (shadcn)
│ ├── project/ # 项目相关组件
│ ├── repository/ # 仓库相关组件
│ └── room/ # 聊天相关组件
├── contexts/ # React Context (用户、聊天室等)
├── client/ # OpenAPI 生成的客户端
└── lib/ # 工具函数与 Hooks
```
## 任务清单
项目当前开发任务详见 [task.md](./task.md),按优先级分为:
- **P0** — 阻塞性问题(核心流程不通)
- **P1** — 核心体验(关键功能)
- **P2** — 体验优化(增强功能)
## 许可证
[待添加]

34
apps/app/Cargo.toml Normal file
View File

@ -0,0 +1,34 @@
[package]
name = "app"
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
[dependencies]
tokio = { workspace = true, features = ["full"] }
uuid = { workspace = true }
service = { workspace = true }
api = { workspace = true }
session = { workspace = true }
config = { workspace = true }
db = { workspace = true }
migrate = { workspace = true }
actix-web = { workspace = true }
actix-cors = { workspace = true }
futures = { workspace = true }
slog = "2"
anyhow = { workspace = true }
clap = { workspace = true }
sea-orm = { workspace = true }
serde_json = { workspace = true }
chrono = { workspace = true }
[lints]
workspace = true

12
apps/app/src/args.rs Normal file
View File

@ -0,0 +1,12 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "app")]
#[command(version)]
pub struct ServerArgs {
#[arg(long, short)]
pub bind: Option<String>,
#[arg(long)]
pub workers: Option<usize>,
}

126
apps/app/src/logging.rs Normal file
View File

@ -0,0 +1,126 @@
//! Structured HTTP request logging middleware using slog.
//!
//! Logs every incoming request with method, path, status code,
//! response time, client IP, and authenticated user ID.
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
use futures::future::{LocalBoxFuture, Ready, ok};
use session::SessionExt;
use slog::{error as slog_error, info as slog_info, warn as slog_warn};
use std::sync::Arc;
use std::task::{Context, Poll};
use std::time::Instant;
use uuid::Uuid;
/// Default log format: `{method} {path} {status} {duration_ms}ms`
pub struct RequestLogger {
log: slog::Logger,
}
impl RequestLogger {
pub fn new(log: slog::Logger) -> Self {
Self { log }
}
}
impl<S, B> Transform<S, ServiceRequest> for RequestLogger
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Transform = RequestLoggerMiddleware<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(RequestLoggerMiddleware {
service: Arc::new(service),
log: self.log.clone(),
})
}
}
pub struct RequestLoggerMiddleware<S> {
service: Arc<S>,
log: slog::Logger,
}
impl<S> Clone for RequestLoggerMiddleware<S> {
fn clone(&self) -> Self {
Self {
service: self.service.clone(),
log: self.log.clone(),
}
}
}
impl<S, B> Service<ServiceRequest> for RequestLoggerMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = actix_web::Error> + 'static,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = actix_web::Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.service.poll_ready(cx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let started = Instant::now();
let log = self.log.clone();
let method = req.method().to_string();
let path = req.path().to_string();
let query = req.query_string().to_string();
let remote = req
.connection_info()
.realip_remote_addr()
.map(|s| s.to_string())
.unwrap_or_else(|| "unknown".to_string());
let user_id: Option<Uuid> = req.get_session().user();
let full_path = if query.is_empty() {
path.clone()
} else {
format!("{}?{}", path, query)
};
// Clone the Arc<S> so it can be moved into the async block
let service = self.service.clone();
Box::pin(async move {
let res = service.call(req).await?;
let elapsed = started.elapsed();
let status = res.status();
let status_code = status.as_u16();
let is_health = path == "/health";
if !is_health {
let user_id_str = user_id
.map(|u: Uuid| u.to_string())
.unwrap_or_else(|| "-".to_string());
let log_message = format!(
"HTTP request | method={} | path={} | status={} | duration_ms={} | remote={} | user_id={}",
method,
full_path,
status_code,
elapsed.as_millis(),
remote,
user_id_str
);
match status_code {
200..=299 => slog_info!(&log, "{}", log_message),
400..=499 => slog_warn!(&log, "{}", log_message),
_ => slog_error!(&log, "{}", log_message),
}
}
Ok(res)
})
}
}

210
apps/app/src/main.rs Normal file
View File

@ -0,0 +1,210 @@
use actix_cors::Cors;
use actix_web::cookie::time::Duration;
use actix_web::middleware::Logger;
use actix_web::{App, HttpResponse, HttpServer, cookie::Key, web};
use clap::Parser;
use db::cache::AppCache;
use db::database::AppDatabase;
use sea_orm::ConnectionTrait;
use service::AppService;
use session::SessionMiddleware;
use session::config::{PersistentSession, SessionLifecycle, TtlExtensionPolicy};
use session::storage::RedisClusterSessionStore;
use slog::Drain;
mod args;
mod logging;
use args::ServerArgs;
use config::AppConfig;
use migrate::{Migrator, MigratorTrait};
#[derive(Clone)]
pub struct AppState {
pub db: AppDatabase,
pub cache: AppCache,
}
fn build_slog_logger(level: &str) -> slog::Logger {
let level_filter = match level {
"trace" => 0usize,
"debug" => 1usize,
"info" => 2usize,
"warn" => 3usize,
"error" => 4usize,
_ => 2usize,
};
struct StderrDrain(usize);
impl Drain for StderrDrain {
type Ok = ();
type Err = ();
#[inline]
fn log(&self, record: &slog::Record, _logger: &slog::OwnedKVList) -> Result<(), ()> {
let slog_level = match record.level() {
slog::Level::Trace => 0,
slog::Level::Debug => 1,
slog::Level::Info => 2,
slog::Level::Warning => 3,
slog::Level::Error => 4,
slog::Level::Critical => 5,
};
if slog_level < self.0 {
return Ok(());
}
let _ = eprintln!(
"{} [{}] {}:{} - {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
record.level().to_string(),
record
.file()
.rsplit_once('/')
.map(|(_, s)| s)
.unwrap_or(record.file()),
record.line(),
record.msg(),
);
Ok(())
}
}
let drain = StderrDrain(level_filter);
let drain = std::sync::Mutex::new(drain);
let drain = slog::Fuse::new(drain);
slog::Logger::root(drain, slog::o!())
}
fn build_session_key(cfg: &AppConfig) -> anyhow::Result<Key> {
if let Some(secret) = cfg.env.get("APP_SESSION_SECRET") {
let bytes: Vec<u8> = secret.as_bytes().iter().cycle().take(64).copied().collect();
return Ok(Key::from(&bytes));
}
Ok(Key::generate())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cfg = AppConfig::load();
let log_level = cfg.log_level().unwrap_or_else(|_| "info".to_string());
let log = build_slog_logger(&log_level);
slog::info!(
log,
"Starting {} {}",
cfg.app_name().unwrap_or_default(),
cfg.app_version().unwrap_or_default()
);
let db = AppDatabase::init(&cfg).await?;
slog::info!(log, "Database connected");
let redis_urls = cfg.redis_urls()?;
let store: RedisClusterSessionStore = RedisClusterSessionStore::new(redis_urls).await?;
slog::info!(log, "Redis connected");
let cache = AppCache::init(&cfg).await?;
slog::info!(log, "Cache initialized");
run_migrations(&db, &log).await?;
let session_key = build_session_key(&cfg)?;
let args = ServerArgs::parse();
let service = AppService::new(cfg.clone()).await?;
slog::info!(log, "AppService initialized");
let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
let worker_service = service.clone();
let log_for_http = log.clone();
let log_for_worker = log.clone();
let worker_handle = tokio::spawn(async move {
worker_service
.start_room_workers(shutdown_rx, log_for_worker)
.await
});
let bind_addr = args.bind.unwrap_or_else(|| "127.0.0.1:8080".to_string());
slog::info!(log, "Listening on {}", bind_addr);
HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.supports_credentials()
.max_age(3600);
let session_mw = SessionMiddleware::builder(store.clone(), session_key.clone())
.cookie_name("id".to_string())
.cookie_path("/".to_string())
.cookie_secure(false)
.cookie_http_only(true)
.session_lifecycle(SessionLifecycle::PersistentSession(
PersistentSession::default()
.session_ttl(Duration::days(30))
.session_ttl_extension_policy(TtlExtensionPolicy::OnEveryRequest),
))
.build();
App::new()
.wrap(cors)
.wrap(session_mw)
.wrap(Logger::default().exclude("/health"))
.app_data(web::Data::new(AppState {
db: db.clone(),
cache: cache.clone(),
}))
.app_data(web::Data::new(service.clone()))
.app_data(web::Data::new(cfg.clone()))
.app_data(web::Data::new(db.clone()))
.app_data(web::Data::new(cache.clone()))
.wrap(logging::RequestLogger::new(log_for_http.clone()))
.route("/health", web::get().to(health_check))
.configure(api::route::init_routes)
})
.bind(&bind_addr)?
.run()
.await?;
slog::info!(log, "Server stopped, shutting down room workers");
let _ = shutdown_tx.send(());
let _ = worker_handle.await;
slog::info!(log, "Room workers stopped");
Ok(())
}
async fn run_migrations(db: &AppDatabase, log: &slog::Logger) -> anyhow::Result<()> {
slog::info!(log, "Running database migrations...");
Migrator::up(db.writer(), None)
.await
.map_err(|e| anyhow::anyhow!("Migration failed: {:?}", e))?;
slog::info!(log, "Migrations completed");
Ok(())
}
async fn health_check(state: web::Data<AppState>) -> HttpResponse {
let db_ok = db_ping(&state.db).await;
let cache_ok = cache_ping(&state.cache).await;
let healthy = db_ok && cache_ok;
if healthy {
HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"db": "ok",
"cache": "ok",
}))
} else {
HttpResponse::ServiceUnavailable().json(serde_json::json!({
"status": "unhealthy",
"db": if db_ok { "ok" } else { "error" },
"cache": if cache_ok { "ok" } else { "error" },
}))
}
}
async fn db_ping(db: &AppDatabase) -> bool {
db.query_one_raw(sea_orm::Statement::from_string(
sea_orm::DbBackend::Postgres,
"SELECT 1",
))
.await
.is_ok()
}
async fn cache_ping(cache: &AppCache) -> bool {
cache.conn().await.is_ok()
}

30
apps/email/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "email-server"
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
[[bin]]
name = "email-worker"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true, features = ["full"] }
service = { workspace = true }
db = { workspace = true }
config = { workspace = true }
slog = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true, features = ["serde"] }
[lints]
workspace = true

84
apps/email/src/main.rs Normal file
View File

@ -0,0 +1,84 @@
use clap::Parser;
use config::AppConfig;
use service::AppService;
use slog::{Drain, OwnedKVList, Record};
#[derive(Parser, Debug)]
#[command(name = "email-worker")]
#[command(version)]
struct Args {
#[arg(long, default_value = "info")]
log_level: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let cfg = AppConfig::load();
let log = build_logger(&args.log_level);
slog::info!(log, "Starting email worker");
let service = AppService::new(cfg).await?;
let (shutdown_tx, shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
let log_for_signal = log.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
slog::info!(log_for_signal, "shutting down email worker");
let _ = shutdown_tx.send(());
});
service.start_email_workers(shutdown_rx).await?;
slog::info!(log, "email worker stopped");
Ok(())
}
fn build_logger(level: &str) -> slog::Logger {
let level_filter = match level {
"trace" => 0usize,
"debug" => 1usize,
"info" => 2usize,
"warn" => 3usize,
"error" => 4usize,
_ => 2usize,
};
struct StderrDrain(usize);
impl Drain for StderrDrain {
type Ok = ();
type Err = ();
#[inline]
fn log(&self, record: &Record, _logger: &OwnedKVList) -> Result<(), ()> {
let slog_level = match record.level() {
slog::Level::Trace => 0,
slog::Level::Debug => 1,
slog::Level::Info => 2,
slog::Level::Warning => 3,
slog::Level::Error => 4,
slog::Level::Critical => 5,
};
if slog_level < self.0 {
return Ok(());
}
let _ = eprintln!(
"{} [{}] {}:{} - {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
record.level().to_string(),
record
.file()
.rsplit_once('/')
.map(|(_, s)| s)
.unwrap_or(record.file()),
record.line(),
record.msg(),
);
Ok(())
}
}
let drain = StderrDrain(level_filter);
let drain = std::sync::Mutex::new(drain);
let drain = slog::Fuse::new(drain);
slog::Logger::root(drain, slog::o!())
}

27
apps/git-hook/Cargo.toml Normal file
View File

@ -0,0 +1,27 @@
[package]
name = "git-hook"
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
[dependencies]
tokio = { workspace = true, features = ["full"] }
git = { workspace = true }
db = { workspace = true }
config = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["json"] }
anyhow = { workspace = true }
slog = { workspace = true }
clap = { workspace = true, features = ["derive"] }
tokio-util = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
reqwest = { workspace = true }

10
apps/git-hook/src/args.rs Normal file
View File

@ -0,0 +1,10 @@
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "git-hook")]
#[command(version)]
pub struct HookArgs {
/// Worker ID for this instance. Defaults to the HOOK_POOL_WORKER_ID env var or a generated UUID.
#[arg(long)]
pub worker_id: Option<String>,
}

142
apps/git-hook/src/main.rs Normal file
View File

@ -0,0 +1,142 @@
use clap::Parser;
use config::AppConfig;
use db::cache::AppCache;
use db::database::AppDatabase;
use git::hook::GitServiceHooks;
use slog::{Drain, OwnedKVList, Record};
use tokio::signal;
use tokio_util::sync::CancellationToken;
mod args;
use args::HookArgs;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1. Load configuration
let cfg = AppConfig::load();
// 2. Init slog logging
let log_level = cfg.log_level().unwrap_or_else(|_| "info".to_string());
let log = build_slog_logger(&log_level);
// 3. Connect to database
let db = AppDatabase::init(&cfg).await?;
slog::info!(log, "database connected");
// 4. Connect to Redis cache (also provides the cluster pool for hook queue)
let cache = AppCache::init(&cfg).await?;
slog::info!(log, "cache connected");
// 5. Parse CLI args
let args = HookArgs::parse();
slog::info!(log, "git-hook worker starting";
"worker_id" => %args.worker_id.unwrap_or_else(|| "default".to_string())
);
// 5. Build HTTP client for webhook delivery
let http = reqwest::Client::builder()
.user_agent("Code-Git-Hook/1.0")
.build()
.unwrap_or_else(|_| reqwest::Client::new());
// 6. Build and run git hook service
let hooks = GitServiceHooks::new(
db,
cache.clone(),
cache.redis_pool().clone(),
log.clone(),
cfg,
std::sync::Arc::new(http),
);
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
// Spawn signal handler
let log_clone = log.clone();
tokio::spawn(async move {
let ctrl_c = async {
signal::ctrl_c()
.await
.expect("failed to install CTRL+C handler");
};
#[cfg(unix)]
let term = async {
use tokio::signal::unix::{SignalKind, signal};
let mut sig =
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
sig.recv().await;
};
#[cfg(not(unix))]
let term = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {
slog::info!(log_clone, "received SIGINT, initiating shutdown");
}
_ = term => {
slog::info!(log_clone, "received SIGTERM, initiating shutdown");
}
}
cancel_clone.cancel();
});
hooks.run(cancel).await?;
slog::info!(log, "git-hook worker stopped");
Ok(())
}
fn build_slog_logger(level: &str) -> slog::Logger {
let level_filter = match level {
"trace" => 0usize,
"debug" => 1usize,
"info" => 2usize,
"warn" => 3usize,
"error" => 4usize,
_ => 2usize,
};
struct StderrDrain(usize);
impl Drain for StderrDrain {
type Ok = ();
type Err = ();
#[inline]
fn log(&self, record: &Record, _logger: &OwnedKVList) -> Result<(), ()> {
let slog_level = match record.level() {
slog::Level::Trace => 0,
slog::Level::Debug => 1,
slog::Level::Info => 2,
slog::Level::Warning => 3,
slog::Level::Error => 4,
slog::Level::Critical => 5,
};
if slog_level < self.0 {
return Ok(());
}
let _ = eprintln!(
"{} [{}] {}:{} - {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
record.level().to_string(),
record
.file()
.rsplit_once('/')
.map(|(_, s)| s)
.unwrap_or(record.file()),
record.line(),
record.msg(),
);
Ok(())
}
}
let drain = StderrDrain(level_filter);
let drain = std::sync::Mutex::new(drain);
let drain = slog::Fuse::new(drain);
slog::Logger::root(drain, slog::o!())
}

30
apps/gitserver/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "gitserver"
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
[[bin]]
name = "gitserver"
path = "src/main.rs"
[dependencies]
tokio = { workspace = true, features = ["full"] }
git = { workspace = true }
db = { workspace = true }
config = { workspace = true }
slog = { workspace = true }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true, features = ["serde"] }
[lints]
workspace = true

View File

@ -0,0 +1,94 @@
use clap::Parser;
use config::AppConfig;
use slog::{Drain, OwnedKVList, Record};
#[derive(Parser, Debug)]
#[command(name = "gitserver")]
#[command(version)]
struct Args {
#[arg(long, default_value = "info")]
log_level: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
let cfg = AppConfig::load();
let log = build_logger(&args.log_level);
let http_handle = tokio::spawn(git::http::run_http(cfg.clone(), log.clone()));
let ssh_handle = tokio::spawn(git::ssh::run_ssh(cfg, log.clone()));
tokio::select! {
result = http_handle => {
match result {
Ok(Ok(())) => slog::info!(log, "HTTP server stopped"),
Ok(Err(e)) => slog::error!(log, "HTTP server error: {}", e),
Err(e) => slog::error!(log, "HTTP server task panicked: {}", e),
}
}
result = ssh_handle => {
match result {
Ok(Ok(())) => slog::info!(log, "SSH server stopped"),
Ok(Err(e)) => slog::error!(log, "SSH server error: {}", e),
Err(e) => slog::error!(log, "SSH server task panicked: {}", e),
}
}
_ = tokio::signal::ctrl_c() => {
slog::info!(log, "received shutdown signal");
}
}
slog::info!(log, "shutting down");
Ok(())
}
fn build_logger(level: &str) -> slog::Logger {
let level_filter = match level {
"trace" => 0usize,
"debug" => 1usize,
"info" => 2usize,
"warn" => 3usize,
"error" => 4usize,
_ => 2usize,
};
struct StderrDrain(usize);
impl Drain for StderrDrain {
type Ok = ();
type Err = ();
#[inline]
fn log(&self, record: &Record, _logger: &OwnedKVList) -> Result<(), ()> {
let slog_level = match record.level() {
slog::Level::Trace => 0,
slog::Level::Debug => 1,
slog::Level::Info => 2,
slog::Level::Warning => 3,
slog::Level::Error => 4,
slog::Level::Critical => 5,
};
if slog_level < self.0 {
return Ok(());
}
let _ = eprintln!(
"{} [{}] {}:{} - {}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"),
record.level().to_string(),
record
.file()
.rsplit_once('/')
.map(|(_, s)| s)
.unwrap_or(record.file()),
record.line(),
record.msg(),
);
Ok(())
}
}
let drain = StderrDrain(level_filter);
let drain = std::sync::Mutex::new(drain);
let drain = slog::Fuse::new(drain);
slog::Logger::root(drain, slog::o!())
}

13
apps/migrate/Cargo.toml Normal file
View File

@ -0,0 +1,13 @@
[package]
name = "migrate-cli"
version.workspace = true
edition.workspace = true
[dependencies]
migrate.workspace = true
sea-orm = { workspace = true, features = ["sqlx-all", "runtime-tokio"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
anyhow.workspace = true
clap.workspace = true
dotenvy.workspace = true
config = { workspace = true }

102
apps/migrate/src/main.rs Normal file
View File

@ -0,0 +1,102 @@
use anyhow::Context;
use clap::Command;
use migrate::MigratorTrait;
use sea_orm::{Database, DatabaseConnection};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
config::AppConfig::load();
let cmd = Command::new("migrate")
.about("Database migration CLI")
.arg(
clap::Arg::new("steps")
.help("Number of migrations (for up/down)")
.required(false)
.index(1),
)
.subcommand(Command::new("up").about("Apply pending migrations"))
.subcommand(Command::new("down").about("Revert applied migrations"))
.subcommand(Command::new("fresh").about("Drop all tables and re-apply"))
.subcommand(Command::new("refresh").about("Revert all then re-apply"))
.subcommand(Command::new("reset").about("Revert all applied migrations"))
.subcommand(Command::new("status").about("Show migration status"))
.try_get_matches()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let db_url = config::AppConfig::load().database_url()?;
let db: DatabaseConnection = Database::connect(&db_url).await?;
match cmd.subcommand_name() {
Some("up") => {
let steps = cmd
.get_one::<String>("steps")
.and_then(|s| s.parse().ok())
.unwrap_or(0);
run_up(&db, steps).await?;
}
Some("down") => {
let steps = cmd
.get_one::<String>("steps")
.and_then(|s| s.parse().ok())
.unwrap_or(1);
run_down(&db, steps).await?;
}
Some("fresh") => run_fresh(&db).await?,
Some("refresh") => run_refresh(&db).await?,
Some("reset") => run_reset(&db).await?,
Some("status") => run_status(&db).await?,
_ => {
eprintln!(
"Usage: migrate <command>\nCommands: up, down, fresh, refresh, reset, status"
);
std::process::exit(1);
}
}
Ok(())
}
async fn run_up(db: &DatabaseConnection, steps: u32) -> anyhow::Result<()> {
migrate::Migrator::up(db, if steps == 0 { None } else { Some(steps) })
.await
.context("failed to run migrations up")?;
Ok(())
}
async fn run_down(db: &DatabaseConnection, steps: u32) -> anyhow::Result<()> {
migrate::Migrator::down(db, Some(steps))
.await
.context("failed to run migrations down")?;
Ok(())
}
async fn run_fresh(db: &DatabaseConnection) -> anyhow::Result<()> {
migrate::Migrator::fresh(db)
.await
.context("failed to run migrations fresh")?;
Ok(())
}
async fn run_refresh(db: &DatabaseConnection) -> anyhow::Result<()> {
migrate::Migrator::refresh(db)
.await
.context("failed to run migrations refresh")?;
Ok(())
}
async fn run_reset(db: &DatabaseConnection) -> anyhow::Result<()> {
migrate::Migrator::reset(db)
.await
.context("failed to run migrations reset")?;
Ok(())
}
async fn run_status(db: &DatabaseConnection) -> anyhow::Result<()> {
migrate::Migrator::status(db)
.await
.context("failed to get migration status")?;
Ok(())
}

30
apps/operator/Cargo.toml Normal file
View File

@ -0,0 +1,30 @@
[package]
name = "operator"
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
[dependencies]
kube = { workspace = true }
k8s-openapi = { workspace = true }
serde = { workspace = true }
serde_json.workspace = true
serde_yaml = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] }
anyhow.workspace = true
futures.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
chrono = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[lints]
workspace = true

View File

@ -0,0 +1,44 @@
//! Shared reconcile context.
use kube::Client;
/// Context passed to every reconcile call.
#[derive(Clone)]
pub struct ReconcileCtx {
pub client: Client,
/// Default image registry prefix (e.g. "myapp/").
pub image_prefix: String,
/// Operator's own namespace.
pub operator_namespace: String,
}
impl ReconcileCtx {
pub async fn from_env() -> anyhow::Result<Self> {
let client = Client::try_default().await?;
let ns = std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string());
let prefix =
std::env::var("OPERATOR_IMAGE_PREFIX").unwrap_or_else(|_| "myapp/".to_string());
Ok(Self {
client,
image_prefix: prefix,
operator_namespace: ns,
})
}
/// Prepend image_prefix to an unqualified image name.
/// E.g. "app:latest" → "myapp/app:latest"
pub fn resolve_image(&self, image: &str) -> String {
// If it already has a registry/domain component, leave it alone.
if image.contains('/') && !image.starts_with(&self.image_prefix) {
image.to_string()
} else if image.starts_with(&self.image_prefix) {
image.to_string()
} else {
// Unqualified name: prepend prefix.
format!("{}{}", self.image_prefix, image)
}
}
}
pub type ReconcileState = ReconcileCtx;

View File

@ -0,0 +1,221 @@
//! Controller for the `App` CRD — manages Deployment + Service.
use crate::context::ReconcileState;
use crate::controller::helpers::{
child_meta, env_var_to_json, merge_env, owner_ref, query_deployment_status, std_labels,
};
use crate::crd::{App, AppSpec};
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::info;
/// Reconcile an App resource: create/update Deployment + Service.
pub async fn reconcile(app: Arc<App>, ctx: Arc<ReconcileState>) -> Result<(), kube::Error> {
let ns = app.metadata.namespace.as_deref().unwrap_or("default");
let name = app.metadata.name.as_deref().unwrap_or("");
let spec = &app.spec;
let client = &ctx.client;
let or = owner_ref(&app.metadata, &app.api_version, &app.kind);
let labels = std_labels();
// ---- Deployment ----
let deployment = build_deployment(ns, name, spec, &or, &labels);
apply_deployment(client, ns, name, &deployment).await?;
// ---- Service ----
let service = build_service(ns, name, &or, &labels);
apply_service(client, ns, name, &service).await?;
// ---- Status patch ----
let (ready_replicas, phase) = query_deployment_status(client, ns, name).await?;
let status = json!({
"status": {
"readyReplicas": ready_replicas,
"phase": phase
}
});
patch_status::<App>(client, ns, name, &status).await?;
Ok(())
}
fn build_deployment(
ns: &str,
name: &str,
spec: &AppSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
let env = merge_env(&[], &spec.env);
let image = if spec.image.is_empty() {
"myapp/app:latest".to_string()
} else {
spec.image.clone()
};
let pull = if spec.image_pull_policy.is_empty() {
"IfNotPresent".to_string()
} else {
spec.image_pull_policy.clone()
};
let resources = build_resources(&spec.resources);
let liveness = spec.liveness_probe.as_ref().map(|p| {
json!({
"httpGet": { "path": p.path, "port": p.port },
"initialDelaySeconds": p.initial_delay_seconds,
"periodSeconds": 10,
})
});
let readiness = spec.readiness_probe.as_ref().map(|p| {
json!({
"httpGet": { "path": p.path, "port": p.port },
"initialDelaySeconds": p.initial_delay_seconds,
"periodSeconds": 5,
})
});
json!({
"metadata": child_meta(name, ns, or, labels.clone()),
"spec": {
"replicas": spec.replicas,
"selector": { "matchLabels": labels },
"strategy": {
"type": "RollingUpdate",
"rollingUpdate": { "maxSurge": 1, "maxUnavailable": 0 }
},
"template": {
"metadata": { "labels": labels.clone() },
"spec": {
"containers": [{
"name": "app",
"image": image,
"ports": [{ "containerPort": 8080 }],
"env": env.iter().map(env_var_to_json).collect::<Vec<_>>(),
"imagePullPolicy": pull,
"resources": resources,
"livenessProbe": liveness,
"readinessProbe": readiness,
}]
}
}
}
})
}
fn build_service(
ns: &str,
name: &str,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
json!({
"metadata": child_meta(name, ns, or, labels.clone()),
"spec": {
"ports": [{ "port": 80, "targetPort": 8080, "name": "http" }],
"selector": labels.clone(),
"type": "ClusterIP"
}
})
}
pub(crate) fn build_resources(res: &Option<crate::crd::ResourceRequirements>) -> Value {
match res {
Some(r) => {
let mut out = serde_json::Map::new();
if let Some(ref req) = r.requests {
let mut req_map = serde_json::Map::new();
if let Some(ref cpu) = req.cpu {
req_map.insert("cpu".to_string(), json!(cpu));
}
if let Some(ref mem) = req.memory {
req_map.insert("memory".to_string(), json!(mem));
}
if !req_map.is_empty() {
out.insert("requests".to_string(), Value::Object(req_map));
}
}
if let Some(ref lim) = r.limits {
let mut lim_map = serde_json::Map::new();
if let Some(ref cpu) = lim.cpu {
lim_map.insert("cpu".to_string(), json!(cpu));
}
if let Some(ref mem) = lim.memory {
lim_map.insert("memory".to_string(), json!(mem));
}
if !lim_map.is_empty() {
out.insert("limits".to_string(), Value::Object(lim_map));
}
}
if out.is_empty() {
json!({})
} else {
Value::Object(out)
}
}
None => json!({}),
}
}
pub(crate) async fn apply_deployment(
client: &kube::Client,
ns: &str,
name: &str,
body: &Value,
) -> Result<(), kube::Error> {
let api: kube::Api<crate::crd::JsonResource> = kube::Api::namespaced(client.clone(), ns);
let jr = crate::crd::JsonResource::new(Default::default(), body.clone());
match api.get(name).await {
Ok(_) => {
info!(name, ns, "updating app deployment");
let _ = api
.replace(name, &kube::api::PostParams::default(), &jr)
.await?;
}
Err(kube::Error::Api(e)) if e.code == 404 => {
info!(name, ns, "creating app deployment");
let _ = api.create(&kube::api::PostParams::default(), &jr).await?;
}
Err(e) => return Err(e),
}
Ok(())
}
pub(crate) async fn apply_service(
client: &kube::Client,
ns: &str,
name: &str,
body: &Value,
) -> Result<(), kube::Error> {
let api: kube::Api<crate::crd::JsonResource> = kube::Api::namespaced(client.clone(), ns);
let jr = crate::crd::JsonResource::new(Default::default(), body.clone());
match api.get(name).await {
Ok(_) => {
let _ = api
.replace(name, &kube::api::PostParams::default(), &jr)
.await?;
}
Err(kube::Error::Api(e)) if e.code == 404 => {
let _ = api.create(&kube::api::PostParams::default(), &jr).await?;
}
Err(e) => return Err(e),
}
Ok(())
}
pub(crate) async fn patch_status<T: Clone + serde::de::DeserializeOwned + std::fmt::Debug>(
client: &kube::Client,
ns: &str,
name: &str,
body: &Value,
) -> Result<(), kube::Error> {
let api: kube::Api<crate::crd::JsonResource> = kube::Api::namespaced(client.clone(), ns);
let _ = api
.patch_status(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(body),
)
.await?;
Ok(())
}

View File

@ -0,0 +1,68 @@
//! Controller for the `EmailWorker` CRD — Deployment only.
use crate::context::ReconcileState;
use crate::controller::app::{apply_deployment, patch_status};
use crate::controller::helpers::{child_meta, env_var_to_json, merge_env, owner_ref, query_deployment_status, std_labels};
use crate::crd::{EmailWorker, EmailWorkerSpec};
use serde_json::{Value, json};
use std::sync::Arc;
pub async fn reconcile(ew: Arc<EmailWorker>, ctx: Arc<ReconcileState>) -> Result<(), kube::Error> {
let ns = ew.metadata.namespace.as_deref().unwrap_or("default");
let name = ew.metadata.name.as_deref().unwrap_or("");
let spec = &ew.spec;
let client = &ctx.client;
let or = owner_ref(&ew.metadata, &ew.api_version, &ew.kind);
let labels = std_labels();
let deployment = build_deployment(ns, name, spec, &or, &labels);
apply_deployment(client, ns, name, &deployment).await?;
let (ready_replicas, phase) = query_deployment_status(client, ns, name).await?;
let status = json!({ "status": { "readyReplicas": ready_replicas, "phase": phase } });
patch_status::<EmailWorker>(client, ns, name, &status).await?;
Ok(())
}
fn build_deployment(
ns: &str,
name: &str,
spec: &EmailWorkerSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
let env = merge_env(&[], &spec.env);
let image = if spec.image.is_empty() {
"myapp/email-worker:latest".to_string()
} else {
spec.image.clone()
};
let pull = if spec.image_pull_policy.is_empty() {
"IfNotPresent".to_string()
} else {
spec.image_pull_policy.clone()
};
let resources = super::app::build_resources(&spec.resources);
json!({
"metadata": child_meta(name, ns, or, labels.clone()),
"spec": {
"replicas": 1,
"selector": { "matchLabels": labels },
"template": {
"metadata": { "labels": labels.clone() },
"spec": {
"containers": [{
"name": "email-worker",
"image": image,
"env": env.iter().map(env_var_to_json).collect::<Vec<_>>(),
"imagePullPolicy": pull,
"resources": resources,
}]
}
}
}
})
}

View File

@ -0,0 +1,137 @@
//! Controller for the `GitHook` CRD — Deployment + ConfigMap.
use crate::context::ReconcileState;
use crate::controller::app::{apply_deployment, patch_status};
use crate::controller::helpers::{child_meta, env_var_to_json, merge_env, owner_ref, query_deployment_status, std_labels};
use crate::crd::{GitHook, GitHookSpec, JsonResource};
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::info;
pub async fn reconcile(gh: Arc<GitHook>, ctx: Arc<ReconcileState>) -> Result<(), kube::Error> {
let ns = gh.metadata.namespace.as_deref().unwrap_or("default");
let name = gh.metadata.name.as_deref().unwrap_or("");
let spec = &gh.spec;
let client = &ctx.client;
let or = owner_ref(&gh.metadata, &gh.api_version, &gh.kind);
let labels = std_labels();
let cm_name = format!("{}-config", name);
// ---- ConfigMap ----
let configmap = build_configmap(ns, &cm_name, &or, &labels);
apply_configmap(client, ns, &cm_name, &configmap).await?;
// ---- Deployment ----
let deployment = build_deployment(ns, name, &cm_name, spec, &or, &labels);
apply_deployment(client, ns, name, &deployment).await?;
let (ready_replicas, phase) = query_deployment_status(client, ns, name).await?;
let status = json!({ "status": { "readyReplicas": ready_replicas, "phase": phase } });
patch_status::<GitHook>(client, ns, name, &status).await?;
Ok(())
}
fn build_configmap(
ns: &str,
cm_name: &str,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
let pool_config = serde_yaml::to_string(&serde_json::json!({
"max_concurrent": 8,
"cpu_threshold": 80.0,
"redis_list_prefix": "{hook}",
"redis_log_channel": "hook:logs",
"redis_block_timeout_secs": 5,
"redis_max_retries": 3,
}))
.unwrap_or_default();
json!({
"metadata": child_meta(cm_name, ns, or, labels.clone()),
"data": {
"pool.yaml": pool_config
}
})
}
fn build_deployment(
ns: &str,
name: &str,
cm_name: &str,
spec: &GitHookSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
let env = merge_env(&[], &spec.env);
let image = if spec.image.is_empty() {
"myapp/git-hook:latest".to_string()
} else {
spec.image.clone()
};
let pull = if spec.image_pull_policy.is_empty() {
"IfNotPresent".to_string()
} else {
spec.image_pull_policy.clone()
};
let resources = super::app::build_resources(&spec.resources);
// Add WORKER_ID env
let worker_id = spec
.worker_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mut env_vars: Vec<serde_json::Value> = env.iter().map(env_var_to_json).collect();
env_vars.push(json!({ "name": "HOOK_POOL_WORKER_ID", "value": worker_id }));
json!({
"metadata": child_meta(name, ns, or, labels.clone()),
"spec": {
"replicas": 1,
"selector": { "matchLabels": labels },
"template": {
"metadata": { "labels": labels.clone() },
"spec": {
"containers": [{
"name": "git-hook",
"image": image,
"env": env_vars,
"imagePullPolicy": pull,
"resources": resources,
"volumeMounts": [{ "name": "hook-config", "mountPath": "/config" }]
}],
"volumes": [{
"name": "hook-config",
"configMap": { "name": cm_name }
}]
}
}
}
})
}
async fn apply_configmap(
client: &kube::Client,
ns: &str,
name: &str,
body: &Value,
) -> Result<(), kube::Error> {
let api: kube::Api<JsonResource> = kube::Api::namespaced(client.clone(), ns);
let jr = JsonResource::new(Default::default(), body.clone());
match api.get(name).await {
Ok(_) => {
let _ = api
.replace(name, &kube::api::PostParams::default(), &jr)
.await?;
Ok(())
}
Err(kube::Error::Api(e)) if e.code == 404 => {
info!(name, ns, "creating git-hook configmap");
let _ = api.create(&kube::api::PostParams::default(), &jr).await?;
Ok(())
}
Err(e) => Err(e),
}
}

View File

@ -0,0 +1,164 @@
//! Controller for the `GitServer` CRD — Deployment + HTTP Svc + SSH Svc + PVC.
use crate::context::ReconcileState;
use crate::controller::app::{apply_deployment, apply_service, patch_status};
use crate::controller::helpers::{child_meta, env_var_to_json, merge_env, owner_ref, query_deployment_status, std_labels};
use crate::crd::{GitServer, GitServerSpec};
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::info;
pub async fn reconcile(gs: Arc<GitServer>, ctx: Arc<ReconcileState>) -> Result<(), kube::Error> {
let ns = gs.metadata.namespace.as_deref().unwrap_or("default");
let name = gs.metadata.name.as_deref().unwrap_or("");
let spec = &gs.spec;
let client = &ctx.client;
let or = owner_ref(&gs.metadata, &gs.api_version, &gs.kind);
let labels = std_labels();
// ---- PVC ----
let pvc = build_pvc(ns, name, spec, &or, &labels);
apply_pvc(client, ns, &format!("{}-repos", name), &pvc).await?;
// ---- Deployment ----
let deployment = build_deployment(ns, name, spec, &or, &labels);
apply_deployment(client, ns, name, &deployment).await?;
// ---- HTTP Service ----
let http_svc = build_http_service(ns, name, spec, &or, &labels);
apply_service(client, ns, &format!("{}-http", name), &http_svc).await?;
// ---- SSH Service ----
let ssh_svc = build_ssh_service(ns, name, spec, &or, &labels);
apply_service(client, ns, &format!("{}-ssh", name), &ssh_svc).await?;
// ---- Status ----
let (ready_replicas, phase) = query_deployment_status(client, ns, name).await?;
let status = json!({ "status": { "readyReplicas": ready_replicas, "phase": phase } });
patch_status::<GitServer>(client, ns, name, &status).await?;
Ok(())
}
fn build_deployment(
ns: &str,
name: &str,
spec: &GitServerSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
let env = merge_env(&[], &spec.env);
let image = if spec.image.is_empty() {
"myapp/gitserver:latest".to_string()
} else {
spec.image.clone()
};
let pull = if spec.image_pull_policy.is_empty() {
"IfNotPresent".to_string()
} else {
spec.image_pull_policy.clone()
};
let resources = super::app::build_resources(&spec.resources);
json!({
"metadata": child_meta(name, ns, or, labels.clone()),
"spec": {
"replicas": 1,
"selector": { "matchLabels": labels },
"template": {
"metadata": { "labels": labels.clone() },
"spec": {
"containers": [{
"name": "gitserver",
"image": image,
"ports": [
{ "name": "http", "containerPort": spec.http_port },
{ "name": "ssh", "containerPort": spec.ssh_port }
],
"env": env.iter().map(env_var_to_json).collect::<Vec<_>>(),
"imagePullPolicy": pull,
"resources": resources,
"volumeMounts": [{ "name": "git-repos", "mountPath": "/data/repos" }]
}],
"volumes": [{
"name": "git-repos",
"persistentVolumeClaim": { "claimName": format!("{}-repos", name) }
}]
}
}
}
})
}
fn build_http_service(
ns: &str,
name: &str,
spec: &GitServerSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
json!({
"metadata": child_meta(&format!("{}-http", name), ns, or, labels.clone()),
"spec": {
"ports": [{ "port": spec.http_port, "targetPort": spec.http_port, "name": "http" }],
"selector": labels.clone(),
"type": "ClusterIP"
}
})
}
fn build_ssh_service(
ns: &str,
name: &str,
spec: &GitServerSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
json!({
"metadata": child_meta(&format!("{}-ssh", name), ns, or, labels.clone()),
"spec": {
"ports": [{ "port": spec.ssh_port, "targetPort": spec.ssh_port, "name": "ssh" }],
"selector": labels.clone(),
"type": spec.ssh_service_type
}
})
}
fn build_pvc(
ns: &str,
name: &str,
spec: &GitServerSpec,
or: &crate::crd::OwnerReference,
labels: &std::collections::BTreeMap<String, String>,
) -> Value {
json!({
"metadata": child_meta(&format!("{}-repos", name), ns, or, labels.clone()),
"spec": {
"accessModes": ["ReadWriteOnce"],
"resources": { "requests": { "storage": spec.storage_size } }
}
})
}
async fn apply_pvc(
client: &kube::Client,
ns: &str,
name: &str,
body: &Value,
) -> Result<(), kube::Error> {
let api: kube::Api<crate::crd::JsonResource> = kube::Api::namespaced(client.clone(), ns);
let jr = crate::crd::JsonResource::new(Default::default(), body.clone());
match api.get(name).await {
Ok(_) => {
/* already exists, don't replace PVC */
Ok(())
}
Err(kube::Error::Api(e)) if e.code == 404 => {
info!(name, ns, "creating gitserver pvc");
let _ = api.create(&kube::api::PostParams::default(), &jr).await?;
Ok(())
}
Err(e) => Err(e),
}
}

View File

@ -0,0 +1,96 @@
//! Shared helpers for building Kubernetes child resources as JSON objects.
use crate::crd::{EnvVar, K8sObjectMeta, OwnerReference};
/// Query a Deployment's actual status and derive the CR's phase.
pub async fn query_deployment_status(
client: &kube::Client,
ns: &str,
name: &str,
) -> Result<(i32, String), kube::Error> {
use k8s_openapi::api::apps::v1::Deployment;
let api: kube::Api<Deployment> = kube::Api::namespaced(client.clone(), ns);
match api.get(name).await {
Ok(d) => {
let ready = d.status.as_ref().and_then(|s| s.ready_replicas).unwrap_or(0);
let phase = if ready > 0 { "Running" } else { "Pending" };
Ok((ready, phase.to_string()))
}
Err(kube::Error::Api(e)) if e.code == 404 => Ok((0, "Pending".to_string())),
Err(e) => Err(e),
}
}
/// Labels applied to every child resource.
pub fn std_labels() -> std::collections::BTreeMap<String, String> {
let mut m = std::collections::BTreeMap::new();
m.insert(
"app.kubernetes.io/managed-by".to_string(),
"code-operator".to_string(),
);
m.insert(
"app.kubernetes.io/part-of".to_string(),
"code-system".to_string(),
);
m
}
pub fn child_meta(
name: &str,
namespace: &str,
owner: &OwnerReference,
labels: std::collections::BTreeMap<String, String>,
) -> K8sObjectMeta {
K8sObjectMeta {
name: Some(name.to_string()),
namespace: Some(namespace.to_string()),
labels: Some(labels),
owner_references: Some(vec![owner.clone().into()]),
..Default::default()
}
}
pub fn owner_ref(parent: &K8sObjectMeta, api_version: &str, kind: &str) -> OwnerReference {
OwnerReference {
api_version: api_version.to_string(),
kind: kind.to_string(),
name: parent.name.clone().unwrap_or_default(),
uid: parent.uid.clone().unwrap_or_default(),
controller: Some(true),
block_owner_deletion: Some(true),
}
}
/// Merge env vars (global first, then local overrides).
pub fn merge_env(global: &[EnvVar], local: &[EnvVar]) -> Vec<EnvVar> {
use std::collections::BTreeMap;
let mut map: BTreeMap<String, EnvVar> = global
.iter()
.cloned()
.map(|e| (e.name.clone(), e))
.collect();
for e in local {
map.insert(e.name.clone(), e.clone());
}
map.into_values().collect()
}
pub fn env_var_to_json(e: &EnvVar) -> serde_json::Value {
use serde_json::json;
let mut m = json!({ "name": e.name });
if let Some(ref v) = e.value {
m["value"] = json!(v);
}
if let Some(ref src) = e.value_from {
if let Some(ref sr) = src.secret_ref {
m["valueFrom"] = json!({
"secretRef": {
"name": sr.secret_name,
"key": sr.secret_key,
}
});
}
}
m
}

View File

@ -0,0 +1,171 @@
//! Controller for the `Migrate` CRD — creates a one-shot Job on reconcile.
//!
//! The Job is re-created on every reconcile (idempotent). Once the Job
//! succeeds, the Migrate status is patched to "Completed".
use crate::context::ReconcileState;
use crate::controller::helpers::{child_meta, env_var_to_json, merge_env, owner_ref, std_labels};
use crate::crd::{JsonResource, K8sObjectMeta, Migrate, MigrateSpec};
use chrono::Utc;
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::info;
pub async fn reconcile(mig: Arc<Migrate>, ctx: Arc<ReconcileState>) -> Result<(), kube::Error> {
let ns = mig.metadata.namespace.as_deref().unwrap_or("default");
let name = mig.metadata.name.as_deref().unwrap_or("");
let spec = &mig.spec;
let client = &ctx.client;
let or = owner_ref(&mig.metadata, &mig.api_version, &mig.kind);
let labels = std_labels();
let job_meta = child_meta(name, ns, &or, labels.clone());
let job = build_job(spec, job_meta, &labels);
// Use JsonResource for Job create/replace (spec part)
let jobs_api: kube::Api<JsonResource> = kube::Api::namespaced(client.clone(), ns);
match jobs_api.get(name).await {
Ok(_) => {
info!(name, ns, "replacing migrate job");
let _ = jobs_api
.replace(name, &kube::api::PostParams::default(), &job)
.await?;
}
Err(kube::Error::Api(e)) if e.code == 404 => {
info!(name, ns, "creating migrate job");
let _ = jobs_api.create(&kube::api::PostParams::default(), &job).await?;
}
Err(e) => return Err(e),
}
// Query real Job status via k8s-openapi (reads status subresource)
let job_status = query_job_status(client, ns, name).await?;
patch_migrate_status_from_job(client, ns, name, &job_status).await?;
Ok(())
}
/// Query actual Job status and derive Migrate phase + timestamps.
async fn query_job_status(
client: &kube::Client,
ns: &str,
name: &str,
) -> Result<JobStatusResult, kube::Error> {
use k8s_openapi::api::batch::v1::Job;
let api: kube::Api<Job> = kube::Api::namespaced(client.clone(), ns);
match api.get(name).await {
Ok(job) => {
let status = job.status.as_ref();
let succeeded = status.and_then(|s| s.succeeded).unwrap_or(0);
let failed = status.and_then(|s| s.failed).unwrap_or(0);
let active = status.and_then(|s| s.active).unwrap_or(0);
let phase = if succeeded > 0 {
"Completed"
} else if failed > 0 {
"Failed"
} else if active > 0 {
"Running"
} else {
"Pending"
};
let start_time = status.and_then(|s| s.start_time.as_ref()).map(|t| t.to_string());
let completion_time = status.and_then(|s| s.completion_time.as_ref()).map(|t| t.to_string());
Ok(JobStatusResult { phase, start_time, completion_time })
}
Err(kube::Error::Api(e)) if e.code == 404 => {
Ok(JobStatusResult { phase: "Pending".to_string(), start_time: None, completion_time: None })
}
Err(e) => Err(e),
}
}
struct JobStatusResult {
phase: String,
start_time: Option<String>,
completion_time: Option<String>,
}
async fn patch_migrate_status_from_job(
client: &kube::Client,
ns: &str,
name: &str,
job: &JobStatusResult,
) -> Result<(), kube::Error> {
let api: kube::Api<JsonResource> = kube::Api::namespaced(client.clone(), ns);
let mut status_obj = json!({ "phase": job.phase });
if let Some(ref st) = job.start_time {
status_obj["startTime"] = json!(st);
}
if let Some(ref ct) = job.completion_time {
status_obj["completionTime"] = json!(ct);
}
let patch = json!({ "status": status_obj });
let _ = api
.patch_status(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(&patch),
)
.await?;
Ok(())
}
fn build_job(
spec: &MigrateSpec,
meta: K8sObjectMeta,
labels: &std::collections::BTreeMap<String, String>,
) -> JsonResource {
let image = if spec.image.is_empty() {
"myapp/migrate:latest".to_string()
} else {
spec.image.clone()
};
let env = merge_env(&[], &spec.env);
let env_vars: Vec<Value> = env.iter().map(env_var_to_json).collect();
let cmd_parts: Vec<&str> = spec.command.split_whitespace().collect();
let cmd: Vec<&str> = if cmd_parts.is_empty() {
vec!["up"]
} else {
cmd_parts
};
let now = Utc::now().to_rfc3339();
let mut meta_with_anno = meta.clone();
meta_with_anno.annotations = Some(std::collections::BTreeMap::from([(
"code.dev/last-migrate".to_string(),
now,
)]));
let body = json!({
"metadata": meta_with_anno,
"spec": {
"backoffLimit": spec.backoff_limit,
"ttlSecondsAfterFinished": 300,
"template": {
"metadata": {
"labels": labels.clone()
},
"spec": {
"restartPolicy": "Never",
"containers": [{
"name": "migrate",
"image": image,
"command": ["/app/migrate"],
"args": cmd,
"env": env_vars,
"imagePullPolicy": "IfNotPresent"
}]
}
}
}
});
JsonResource::new(meta, body)
}

View File

@ -0,0 +1,188 @@
//! Kubernetes Controllers — one per CRD type.
pub mod app;
pub mod email_worker;
pub mod git_hook;
pub mod gitserver;
pub mod helpers;
pub mod migrate;
use crate::context::ReconcileCtx;
use crate::crd::{App, EmailWorker, GitHook, GitServer, Migrate};
use futures::StreamExt;
use kube::runtime::{Controller, controller::Action};
use std::sync::Arc;
fn error_policy<K: std::fmt::Debug>(
obj: Arc<K>,
err: &kube::Error,
_: Arc<ReconcileCtx>,
) -> Action {
tracing::error!(?obj, %err, "reconcile error");
Action::await_change()
}
/// Start the App controller.
pub async fn start_app(client: kube::Client, ctx: Arc<ReconcileCtx>) -> anyhow::Result<()> {
Controller::new(kube::Api::<App>::all(client.clone()), Default::default())
.owns::<k8s_openapi::api::apps::v1::Deployment>(
kube::Api::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::core::v1::Service>(
kube::Api::all(client.clone()),
Default::default(),
)
.run(
|o, c| {
let c = c.clone();
async move {
app::reconcile(o, c).await?;
Ok::<_, kube::Error>(Action::await_change())
}
},
error_policy,
ctx.clone(),
)
.for_each(|r| async move {
if let Err(e) = r {
tracing::error!(%e, "app controller stream error");
}
})
.await;
Ok(())
}
/// Start the GitServer controller.
pub async fn start_gitserver(client: kube::Client, ctx: Arc<ReconcileCtx>) -> anyhow::Result<()> {
Controller::new(
kube::Api::<GitServer>::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::apps::v1::Deployment>(
kube::Api::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::core::v1::Service>(kube::Api::all(client.clone()), Default::default())
.owns::<k8s_openapi::api::core::v1::PersistentVolumeClaim>(
kube::Api::all(client.clone()),
Default::default(),
)
.run(
|o, c| {
let c = c.clone();
async move {
gitserver::reconcile(o, c).await?;
Ok::<_, kube::Error>(Action::await_change())
}
},
error_policy,
ctx.clone(),
)
.for_each(|r| async move {
if let Err(e) = r {
tracing::error!(%e, "gitserver controller stream error");
}
})
.await;
Ok(())
}
/// Start the EmailWorker controller.
pub async fn start_email_worker(
client: kube::Client,
ctx: Arc<ReconcileCtx>,
) -> anyhow::Result<()> {
Controller::new(
kube::Api::<EmailWorker>::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::apps::v1::Deployment>(
kube::Api::all(client.clone()),
Default::default(),
)
.run(
|o, c| {
let c = c.clone();
async move {
email_worker::reconcile(o, c).await?;
Ok::<_, kube::Error>(Action::await_change())
}
},
error_policy,
ctx.clone(),
)
.for_each(|r| async move {
if let Err(e) = r {
tracing::error!(%e, "email_worker controller stream error");
}
})
.await;
Ok(())
}
/// Start the GitHook controller.
pub async fn start_git_hook(client: kube::Client, ctx: Arc<ReconcileCtx>) -> anyhow::Result<()> {
Controller::new(
kube::Api::<GitHook>::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::apps::v1::Deployment>(
kube::Api::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::core::v1::ConfigMap>(
kube::Api::all(client.clone()),
Default::default(),
)
.run(
|o, c| {
let c = c.clone();
async move {
git_hook::reconcile(o, c).await?;
Ok::<_, kube::Error>(Action::await_change())
}
},
error_policy,
ctx.clone(),
)
.for_each(|r| async move {
if let Err(e) = r {
tracing::error!(%e, "git_hook controller stream error");
}
})
.await;
Ok(())
}
/// Start the Migrate controller.
pub async fn start_migrate(client: kube::Client, ctx: Arc<ReconcileCtx>) -> anyhow::Result<()> {
Controller::new(
kube::Api::<Migrate>::all(client.clone()),
Default::default(),
)
.owns::<k8s_openapi::api::batch::v1::Job>(kube::Api::all(client.clone()), Default::default())
.run(
|o, c| {
let c = c.clone();
async move {
migrate::reconcile(o, c).await?;
Ok::<_, kube::Error>(Action::await_change())
}
},
error_policy,
ctx.clone(),
)
.for_each(|r| async move {
if let Err(e) = r {
tracing::error!(%e, "migrate controller stream error");
}
})
.await;
Ok(())
}

581
apps/operator/src/crd.rs Normal file
View File

@ -0,0 +1,581 @@
//! Custom Resource Definitions (CRDs) — plain serde types.
//!
//! API Group: `code.dev`
//!
//! The operator watches these resources using `kube::Api::<MyCrd>::all(client)`.
//! Reconcile is triggered on every change to any instance of these types.
use k8s_openapi::apimachinery::pkg::apis::meta::v1::{
ObjectMeta, OwnerReference as K8sOwnerReference,
};
use kube::Resource;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
// ---------------------------------------------------------------------------
// A dynamic Resource impl for serde_json::Value — lets us use kube::Api<Value>
// ---------------------------------------------------------------------------
/// JsonResource wraps serde_json::Value and implements Resource so we can use
/// `kube::Api<JsonResource>` for arbitrary child-resource API calls.
/// The metadata field is kept separate to satisfy the Resource::meta() bound.
#[derive(Clone, Debug, Default)]
pub struct JsonResource {
meta: ObjectMeta,
body: serde_json::Value,
}
impl JsonResource {
pub fn new(meta: ObjectMeta, body: serde_json::Value) -> Self {
JsonResource { meta, body }
}
}
impl std::ops::Deref for JsonResource {
type Target = serde_json::Value;
fn deref(&self) -> &serde_json::Value {
&self.body
}
}
impl serde::Serialize for JsonResource {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.body.serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for JsonResource {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let body = serde_json::Value::deserialize(d)?;
let meta = body
.get("metadata")
.and_then(|m| serde_json::from_value(m.clone()).ok())
.unwrap_or_default();
Ok(JsonResource { meta, body })
}
}
impl Resource for JsonResource {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &()) -> Cow<'_, str> {
Cow::Borrowed("Object")
}
fn group(_: &()) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn version(_: &()) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &()) -> Cow<'_, str> {
Cow::Borrowed("objects")
}
fn meta(&self) -> &ObjectMeta {
&self.meta
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.meta
}
}
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
/// EnvVar with optional secret reference.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvVar {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value_from: Option<EnvVarSource>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EnvVarSource {
#[serde(skip_serializing_if = "Option::is_none")]
pub secret_ref: Option<SecretEnvVar>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SecretEnvVar {
pub name: String,
pub secret_name: String,
pub secret_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ResourceRequirements {
#[serde(skip_serializing_if = "Option::is_none")]
pub requests: Option<ResourceList>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limits: Option<ResourceList>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ResourceList {
#[serde(skip_serializing_if = "Option::is_none")]
pub cpu: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Probe {
#[serde(default = "default_port")]
pub port: i32,
#[serde(default = "default_path")]
pub path: String,
#[serde(default = "default_initial_delay")]
pub initial_delay_seconds: i32,
}
fn default_port() -> i32 {
8080
}
fn default_path() -> String {
"/health".to_string()
}
fn default_initial_delay() -> i32 {
5
}
// ---------------------------------------------------------------------------
// App CRD
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSpec {
#[serde(default = "default_app_image")]
pub image: String,
#[serde(default = "default_replicas")]
pub replicas: i32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourceRequirements>,
#[serde(skip_serializing_if = "Option::is_none")]
pub liveness_probe: Option<Probe>,
#[serde(skip_serializing_if = "Option::is_none")]
pub readiness_probe: Option<Probe>,
#[serde(default)]
pub image_pull_policy: String,
}
fn default_app_image() -> String {
"myapp/app:latest".to_string()
}
fn default_replicas() -> i32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub ready_replicas: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct App {
pub api_version: String,
pub kind: String,
pub metadata: K8sObjectMeta,
pub spec: AppSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<AppStatus>,
}
impl App {
pub fn api_group() -> &'static str {
"code.dev"
}
pub fn version() -> &'static str {
"v1"
}
pub fn plural() -> &'static str {
"apps"
}
}
impl Resource for App {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("App")
}
fn group(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("code.dev")
}
fn version(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("apps")
}
fn meta(&self) -> &ObjectMeta {
&self.metadata
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.metadata
}
}
// ---------------------------------------------------------------------------
// GitServer CRD
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitServerSpec {
#[serde(default = "default_gitserver_image")]
pub image: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourceRequirements>,
#[serde(default = "default_ssh_service_type")]
pub ssh_service_type: String,
#[serde(default = "default_storage_size")]
pub storage_size: String,
#[serde(default)]
pub image_pull_policy: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ssh_domain: Option<String>,
#[serde(default = "default_ssh_port")]
pub ssh_port: i32,
#[serde(default = "default_http_port")]
pub http_port: i32,
}
fn default_gitserver_image() -> String {
"myapp/gitserver:latest".to_string()
}
fn default_ssh_service_type() -> String {
"NodePort".to_string()
}
fn default_storage_size() -> String {
"10Gi".to_string()
}
fn default_ssh_port() -> i32 {
22
}
fn default_http_port() -> i32 {
8022
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitServerStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub ready_replicas: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitServer {
pub api_version: String,
pub kind: String,
pub metadata: K8sObjectMeta,
pub spec: GitServerSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<GitServerStatus>,
}
impl GitServer {
pub fn api_group() -> &'static str {
"code.dev"
}
pub fn version() -> &'static str {
"v1"
}
pub fn plural() -> &'static str {
"gitservers"
}
}
impl Resource for GitServer {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("GitServer")
}
fn group(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("code.dev")
}
fn version(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("gitservers")
}
fn meta(&self) -> &ObjectMeta {
&self.metadata
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.metadata
}
}
// ---------------------------------------------------------------------------
// EmailWorker CRD
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailWorkerSpec {
#[serde(default = "default_email_image")]
pub image: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourceRequirements>,
#[serde(default)]
pub image_pull_policy: String,
}
fn default_email_image() -> String {
"myapp/email-worker:latest".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmailWorkerStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub ready_replicas: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmailWorker {
pub api_version: String,
pub kind: String,
pub metadata: K8sObjectMeta,
pub spec: EmailWorkerSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<EmailWorkerStatus>,
}
impl EmailWorker {
pub fn api_group() -> &'static str {
"code.dev"
}
pub fn version() -> &'static str {
"v1"
}
pub fn plural() -> &'static str {
"emailworkers"
}
}
impl Resource for EmailWorker {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("EmailWorker")
}
fn group(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("code.dev")
}
fn version(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("emailworkers")
}
fn meta(&self) -> &ObjectMeta {
&self.metadata
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.metadata
}
}
// ---------------------------------------------------------------------------
// GitHook CRD
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHookSpec {
#[serde(default = "default_githook_image")]
pub image: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resources: Option<ResourceRequirements>,
#[serde(default)]
pub image_pull_policy: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
}
fn default_githook_image() -> String {
"myapp/git-hook:latest".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitHookStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub ready_replicas: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHook {
pub api_version: String,
pub kind: String,
pub metadata: K8sObjectMeta,
pub spec: GitHookSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<GitHookStatus>,
}
impl GitHook {
pub fn api_group() -> &'static str {
"code.dev"
}
pub fn version() -> &'static str {
"v1"
}
pub fn plural() -> &'static str {
"githooks"
}
}
impl Resource for GitHook {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("GitHook")
}
fn group(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("code.dev")
}
fn version(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("githooks")
}
fn meta(&self) -> &ObjectMeta {
&self.metadata
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.metadata
}
}
// ---------------------------------------------------------------------------
// Migrate CRD
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrateSpec {
#[serde(default = "default_migrate_image")]
pub image: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub env: Vec<EnvVar>,
#[serde(default = "default_migrate_cmd")]
pub command: String,
#[serde(default = "default_backoff_limit")]
pub backoff_limit: i32,
}
fn default_migrate_image() -> String {
"myapp/migrate:latest".to_string()
}
fn default_migrate_cmd() -> String {
"up".to_string()
}
fn default_backoff_limit() -> i32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MigrateStatus {
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion_time: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Migrate {
pub api_version: String,
pub kind: String,
pub metadata: K8sObjectMeta,
pub spec: MigrateSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<MigrateStatus>,
}
impl Migrate {
pub fn api_group() -> &'static str {
"code.dev"
}
pub fn version() -> &'static str {
"v1"
}
pub fn plural() -> &'static str {
"migrates"
}
}
impl Resource for Migrate {
type DynamicType = ();
type Scope = k8s_openapi::NamespaceResourceScope;
fn kind(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("Migrate")
}
fn group(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("code.dev")
}
fn version(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("v1")
}
fn plural(_: &Self::DynamicType) -> Cow<'_, str> {
Cow::Borrowed("migrates")
}
fn meta(&self) -> &ObjectMeta {
&self.metadata
}
fn meta_mut(&mut self) -> &mut ObjectMeta {
&mut self.metadata
}
}
// ---------------------------------------------------------------------------
// Shared K8s types — aligned with k8s-openapi for Resource trait compatibility
// ---------------------------------------------------------------------------
/// Type alias so K8sObjectMeta satisfies Resource::meta() -> &k8s_openapi::...::ObjectMeta.
pub type K8sObjectMeta = ObjectMeta;
/// OwnerReference compatible with k8s-openapi.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnerReference {
pub api_version: String,
pub kind: String,
pub name: String,
pub uid: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub controller: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_owner_deletion: Option<bool>,
}
impl From<OwnerReference> for K8sOwnerReference {
fn from(o: OwnerReference) -> Self {
K8sOwnerReference {
api_version: o.api_version,
kind: o.kind,
name: o.name,
uid: o.uid,
controller: o.controller,
block_owner_deletion: o.block_owner_deletion,
}
}
}

3
apps/operator/src/lib.rs Normal file
View File

@ -0,0 +1,3 @@
pub mod context;
pub mod controller;
pub mod crd;

100
apps/operator/src/main.rs Normal file
View File

@ -0,0 +1,100 @@
//! Code System Kubernetes Operator
//!
//! Manages the lifecycle of: App, GitServer, EmailWorker, GitHook, Migrate CRDs.
use operator::context::ReconcileCtx;
use std::sync::Arc;
use tracing::{Level, error, info};
use tracing_subscriber::FmtSubscriber;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// ---- Logging ----
let log_level = std::env::var("OPERATOR_LOG_LEVEL").unwrap_or_else(|_| "info".to_string());
let level = match log_level.to_lowercase().as_str() {
"trace" => Level::TRACE,
"debug" => Level::DEBUG,
"info" => Level::INFO,
"warn" => Level::WARN,
"error" => Level::ERROR,
_ => Level::INFO,
};
FmtSubscriber::builder()
.with_max_level(level)
.with_target(false)
.with_thread_ids(false)
.with_file(true)
.with_line_number(true)
.compact()
.init();
let ctx = Arc::new(ReconcileCtx::from_env().await?);
info!(
namespace = ctx.operator_namespace,
image_prefix = ctx.image_prefix,
"code-operator starting"
);
// ---- Spawn all 5 controllers ----
let app_handle = tokio::spawn({
let ctx = ctx.clone();
let client = ctx.client.clone();
async move {
use operator::controller;
if let Err(e) = controller::start_app(client, ctx).await {
error!(%e, "app controller stopped");
}
}
});
let gs_handle = tokio::spawn({
let ctx = ctx.clone();
let client = ctx.client.clone();
async move {
use operator::controller;
if let Err(e) = controller::start_gitserver(client, ctx).await {
error!(%e, "gitserver controller stopped");
}
}
});
let ew_handle = tokio::spawn({
let ctx = ctx.clone();
let client = ctx.client.clone();
async move {
use operator::controller;
if let Err(e) = controller::start_email_worker(client, ctx).await {
error!(%e, "email_worker controller stopped");
}
}
});
let gh_handle = tokio::spawn({
let ctx = ctx.clone();
let client = ctx.client.clone();
async move {
use operator::controller;
if let Err(e) = controller::start_git_hook(client, ctx).await {
error!(%e, "git_hook controller stopped");
}
}
});
let mig_handle = tokio::spawn({
let ctx = ctx.clone();
let client = ctx.client.clone();
async move {
use operator::controller;
if let Err(e) = controller::start_migrate(client, ctx).await {
error!(%e, "migrate controller stopped");
}
}
});
// ---- Graceful shutdown on SIGINT / SIGTERM ----
tokio::signal::ctrl_c().await.ok();
info!("code-operator stopped");
let _ = tokio::join!(app_handle, gs_handle, ew_handle, gh_handle, mig_handle,);
Ok(())
}

22
apps/static/Cargo.toml Normal file
View File

@ -0,0 +1,22 @@
[package]
name = "static-server"
version.workspace = true
edition.workspace = true
[dependencies]
actix-web = { workspace = true }
actix-files = { workspace = true }
actix-cors = { workspace = true }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true }
serde_json = { workspace = true }
mime = { workspace = true }
mime_guess2 = { workspace = true }
slog = { workspace = true }
anyhow = { workspace = true }
env_logger = { workspace = true }
[profile.release]
strip = true
lto = "thin"
opt-level = 3

110
apps/static/src/main.rs Normal file
View File

@ -0,0 +1,110 @@
use actix_cors::Cors;
use actix_files::Files;
use actix_web::{http::header, middleware::Logger, web, App, HttpResponse, HttpServer};
use std::path::PathBuf;
/// Static file server for avatar, blob, and other static files
/// Serves files from /data/{type} directories
#[derive(Clone)]
struct StaticConfig {
root: PathBuf,
cors_enabled: bool,
}
impl StaticConfig {
fn from_env() -> Self {
let root = std::env::var("STATIC_ROOT").unwrap_or_else(|_| "/data".to_string());
let cors = std::env::var("STATIC_CORS").unwrap_or_else(|_| "true".to_string());
Self {
root: PathBuf::from(root),
cors_enabled: cors == "true" || cors == "1",
}
}
fn ensure_dir(&self, name: &str) -> PathBuf {
let dir = self.root.join(name);
if !dir.exists() {
std::fs::create_dir_all(&dir).ok();
}
dir
}
}
async fn health() -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({
"status": "ok",
"service": "static-server"
}))
}
#[actix_web::main]
async fn main() -> anyhow::Result<()> {
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let cfg = StaticConfig::from_env();
let bind = std::env::var("STATIC_BIND").unwrap_or_else(|_| "0.0.0.0:8081".to_string());
println!("Static file server starting...");
println!(" Root: {:?}", cfg.root);
println!(" Bind: {}", bind);
println!(" CORS: {}", if cfg.cors_enabled { "enabled" } else { "disabled" });
// Ensure all directories exist
for name in ["avatar", "blob", "media", "static"] {
let dir = cfg.ensure_dir(name);
println!(" {} dir: {:?}", name, dir);
}
let root = cfg.root.clone();
let cors_enabled = cfg.cors_enabled;
HttpServer::new(move || {
let root = root.clone();
let cors = if cors_enabled {
Cors::default()
.allow_any_origin()
.allowed_methods(vec!["GET", "HEAD", "OPTIONS"])
.allowed_headers(vec![
header::AUTHORIZATION,
header::ACCEPT,
header::CONTENT_TYPE,
])
.max_age(3600)
} else {
Cors::permissive()
};
App::new()
.wrap(cors)
.wrap(Logger::default())
.route("/health", web::get().to(health))
.service(
Files::new("/avatar", root.join("avatar"))
.prefer_utf8(true)
.index_file("index.html"),
)
.service(
Files::new("/blob", root.join("blob"))
.prefer_utf8(true)
.index_file("index.html"),
)
.service(
Files::new("/media", root.join("media"))
.prefer_utf8(true)
.index_file("index.html"),
)
.service(
Files::new("/static", root.join("static"))
.prefer_utf8(true)
.index_file("index.html"),
)
})
.bind(&bind)?
.run()
.await?;
Ok(())
}

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

14
deploy/Chart.yaml Normal file
View File

@ -0,0 +1,14 @@
apiVersion: v2
name: gitdata
description: Self-hosted GitHub + Slack alternative platform
type: application
version: 0.1.0
appVersion: "0.1.0"
keywords:
- git
- collaboration
- self-hosted
maintainers:
- name: gitdata Team
email: team@c.dev

66
deploy/configmap.yaml Normal file
View File

@ -0,0 +1,66 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: gitdata-config
namespace: gitdataai
labels:
app.kubernetes.io/name: gitdata
app.kubernetes.io/instance: gitdata
app.kubernetes.io/version: "0.1.0"
data:
# App Info
APP_NAME: "gitdata"
APP_VERSION: "0.1.0"
APP_STATIC_DOMAIN: "https://static.gitdata.ai"
APP_MEDIA_DOMAIN: "https://static.gitdata.ai"
APP_GIT_HTTP_DOMAIN: "https://git.gitdata.ai"
APP_AVATAR_PATH: "/data/avatar"
APP_REPOS_ROOT: "/data/repos"
APP_DATABASE_URL: "postgresql://gitdataai:gitdataai123@cnpg-cluster-rw.cnpg:5432/gitdataai?sslmode=disable"
APP_DATABASE_MAX_CONNECTIONS: "100"
APP_DATABASE_MIN_CONNECTIONS: "5"
APP_DATABASE_IDLE_TIMEOUT: "600"
APP_DATABASE_MAX_LIFETIME: "3600"
APP_DATABASE_CONNECTION_TIMEOUT: "30"
APP_DATABASE_SCHEMA_SEARCH_PATH: "public"
APP_DATABASE_HEALTH_CHECK_INTERVAL: "30"
APP_DATABASE_RETRY_ATTEMPTS: "3"
APP_DATABASE_RETRY_DELAY: "1"
APP_REDIS_URL: "redis://default:redis123@valkey-cluster.valkey-cluster.svc.cluster.local:6379"
APP_REDIS_POOL_SIZE: "16"
APP_REDIS_CONNECT_TIMEOUT: "5"
APP_REDIS_ACQUIRE_TIMEOUT: "1"
NATS_URL: "nats://nats-client.nats.svc.cluster.local:4222"
HOOK_POOL_MAX_CONCURRENT: "100"
HOOK_POOL_CPU_THRESHOLD: "80"
HOOK_POOL_REDIS_LIST_PREFIX: "{hook}"
HOOK_POOL_REDIS_LOG_CHANNEL: "hook:logs"
HOOK_POOL_REDIS_BLOCK_TIMEOUT: "5"
HOOK_POOL_REDIS_MAX_RETRIES: "3"
APP_LOG_LEVEL: "info"
APP_LOG_FORMAT: "json"
APP_LOG_FILE_ENABLED: "false"
APP_LOG_FILE_PATH: "/var/log/gitdata/app.log"
APP_LOG_FILE_ROTATION: "daily"
APP_LOG_FILE_MAX_FILES: "7"
APP_LOG_FILE_MAX_SIZE: "100"
APP_OTEL_ENABLED: "false"
APP_OTEL_ENDPOINT: ""
APP_OTEL_SERVICE_NAME: "gitdata"
APP_OTEL_SERVICE_VERSION: "0.1.0"
APP_SMTP_HOST: "smtp.exmail.qq.com"
APP_SMTP_PORT: "465"
APP_SMTP_USERNAME: "gitdata-bot@gitdata.ai"
APP_SMTP_PASSWORD: "Dha88YLtNicGUj4G"
APP_SMTP_FROM: "gitdata-bot@gitdata.ai"
APP_SMTP_TLS: "true"
APP_SMTP_TIMEOUT: "30"
APP_SSH_DOMAIN: "git.gitdata.ai"
APP_SSH_PORT: "22"
APP_AI_BASIC_URL: "https://axonhub.gitdata.me/v1"
APP_AI_API_KEY: "ah-629e2cfb5a58f6b7053cd890c6bd6c0de4537fa2f816ccc984090d022a50262e"
APP_EMBED_MODEL_BASE_URL: "https://api.siliconflow.cn/v1"
APP_EMBED_MODEL_API_KEY: "sk-xzehcnpedeijpgyiitcalzhlcdrmyujezubudvpacukyvzmo"
APP_EMBED_MODEL_NAME: "BAAI/bge-m3"
APP_EMBED_MODEL_DIMENSIONS: "1024"
APP_QDRANT_URL: "http://qdrant.qdrant.svc.cluster.local:6333"

View File

@ -0,0 +1,71 @@
# =============================================================================
# Secrets Configuration - 示例文件 (外部 Secret Manager)
# =============================================================================
# 生产环境使用 External Secrets Operator (ESO) 从 Vault/AWS SM/Azure KeyVault 同步
# https://external-secrets.io/
#
# 密钥管理器需要预先配置 SecretStore例如 Vault:
# apiVersion: external-secrets.io/v1beta1
# kind: SecretStore
# metadata:
# name: vault-backend
# namespace: gitdataai
# spec:
# vault:
# server: "https://vault.example.com"
# pathPrefix: /secret
# auth:
# kubernetes:
# mountPath: kubernetes
# role: gitdata
#
# 密钥路径约定:
# gitdata/database → { url: "postgresql://..." }
# gitdata/redis → { url: "redis://..." }
# gitdata/qdrant → { apiKey: "..." }
# =============================================================================
# -----------------------------------------------------------------------------
# External Secrets 配置
# -----------------------------------------------------------------------------
externalSecrets:
# SecretStore / ClusterSecretStore 名称 (集群预先配置)
storeName: "vault-backend"
storeKind: "SecretStore" # 或 ClusterSecretStore (跨 namespace)
# Vault 密钥路径
databaseKey: "gitdata/database"
redisKey: "gitdata/redis"
qdrantKey: "gitdata/qdrant"
# -----------------------------------------------------------------------------
# Secret 名称 (与 ExternalSecret target.name 对应)
# -----------------------------------------------------------------------------
database:
existingSecret: "gitdata-database-secret"
secretKeys:
url: APP_DATABASE_URL
redis:
existingSecret: "gitdata-redis-secret"
secretKeys:
url: APP_REDIS_URL
# -----------------------------------------------------------------------------
# Qdrant (启用 AI 功能时需要)
# -----------------------------------------------------------------------------
qdrant:
enabled: true
url: "http://qdrant.qdrant.svc.cluster.local:6333"
existingSecret: "gitdata-qdrant-secret"
secretKeys:
apiKey: APP_QDRANT_API_KEY
# -----------------------------------------------------------------------------
# 本地开发 / CI/CD 快速部署 (secrets.create: true)
# 生产环境请使用 externalSecrets 配置
# -----------------------------------------------------------------------------
# secrets:
# create: true
# databaseUrl: "postgresql://..."
# redisUrl: "redis://..."

View File

@ -0,0 +1,35 @@
{{/* Helm NOTES.txt shown after install/upgrade */}}
{{- if .Release.IsInstall }}
🎉 {{ .Chart.Name }} {{ .Chart.Version }} installed in namespace {{ .Release.Namespace }}.
⚠️ Prerequisites you must fulfil before the app starts:
1. PostgreSQL database is reachable.
2. Redis is reachable.
3. (Optional) NATS if HOOK_POOL is enabled.
4. (Optional) Qdrant if AI embeddings are used.
📋 Required Secret "{{ .Release.Name }}-secrets" (create manually or via external secrets):
apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-secrets
namespace: {{ .Release.Namespace }}
type: Opaque
stringData:
APP_DATABASE_URL: postgresql://user:password@postgres:5432/db
APP_REDIS_URL: redis://redis:6379
# APP_SMTP_PASSWORD: ...
# APP_QDRANT_API_KEY: ...
Or set .Values.secrets in values.yaml.
🔄 To run database migrations:
helm upgrade {{ .Release.Name }} ./gitdata -n {{ .Release.Namespace }} \
--set migrate.enabled=true
📖 Useful commands:
kubectl get pods -n {{ .Release.Namespace }}
kubectl logs -n {{ .Release.Namespace }} -l app.kubernetes.io/name={{ .Chart.Name }}
{{- end }}

View File

@ -0,0 +1,44 @@
{{/* =============================================================================
Common helpers
============================================================================= */}}
{{- define "gitdata.fullname" -}}
{{- .Release.Name -}}
{{- end -}}
{{- define "gitdata.namespace" -}}
{{- .Values.namespace | default .Release.Namespace -}}
{{- end -}}
{{- define "gitdata.image" -}}
{{- $registry := .Values.image.registry -}}
{{- $pullPolicy := .Values.image.pullPolicy -}}
{{- printf "%s/%s:%s" $registry .image.repository .image.tag -}}
{{- end -}}
{{/* Inject image pull policy into sub-chart image dict */}}
{{- define "gitdata.mergeImage" -}}
{{- $merged := dict "pullPolicy" $.Values.image.pullPolicy -}}
{{- $merged = merge $merged .image -}}
{{- printf "%s/%s:%s" $.Values.image.registry $merged.repository $merged.tag -}}
{{- end -}}
{{/* Build a key-value env var list, optionally reading from a Secret */}}
{{- define "gitdata.envFromSecret" -}}
{{- $secretName := .existingSecret -}}
{{- $keys := .secretKeys -}}
{{- $result := list -}}
{{- range $envName, $secretKey := $keys -}}
{{- $item := dict "name" $envName "valueFrom" (dict "secretKeyRef" (dict "name" $secretName "key" $secretKey)) -}}
{{- $result = append $result $item -}}
{{- end -}}
{{- $result | toJson | fromJson -}}
{{- end -}}
{{/* Merge two env lists (extra env over auto-injected) */}}
{{- define "gitdata.mergeEnv" -}}
{{- $auto := .auto -}}
{{- $extra := .extra | default list -}}
{{- $merged := append $auto $extra | toJson | fromJson -}}
{{- $merged | toYaml -}}
{{- end -}}

View File

@ -0,0 +1,150 @@
{{- if .Values.app.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gitdata.fullname" . }}-app
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ .Values.app.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
terminationGracePeriodSeconds: 30
containers:
- name: app
image: "{{ .Values.image.registry }}/{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}"
imagePullPolicy: {{ .Values.app.image.pullPolicy | default .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.app.service.port }}
protocol: TCP
env:
- name: APP_DATABASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_DATABASE_URL
optional: true
- name: APP_REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_REDIS_URL
optional: true
- name: NATS_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: NATS_URL
optional: true
- name: HOOK_POOL_REDIS_LIST_PREFIX
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: HOOK_POOL_REDIS_LIST_PREFIX
optional: true
- name: HOOK_POOL_REDIS_LOG_CHANNEL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: HOOK_POOL_REDIS_LOG_CHANNEL
optional: true
- name: APP_QDRANT_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_QDRANT_URL
optional: true
- name: APP_QDRANT_API_KEY
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_QDRANT_API_KEY
optional: true
- name: APP_AVATAR_PATH
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_AVATAR_PATH
optional: true
{{- range .Values.app.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
livenessProbe:
httpGet:
path: {{ .Values.app.livenessProbe.path }}
port: {{ .Values.app.livenessProbe.port }}
initialDelaySeconds: {{ .Values.app.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.app.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: {{ .Values.app.readinessProbe.path }}
port: {{ .Values.app.readinessProbe.port }}
initialDelaySeconds: {{ .Values.app.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.app.readinessProbe.periodSeconds }}
{{- if .Values.app.startupProbe }}
startupProbe:
httpGet:
path: {{ .Values.app.startupProbe.path }}
port: {{ .Values.app.startupProbe.port }}
initialDelaySeconds: {{ .Values.app.startupProbe.initialDelaySeconds | default 0 }}
periodSeconds: {{ .Values.app.startupProbe.periodSeconds }}
failureThreshold: {{ .Values.app.startupProbe.failureThreshold }}
{{- end }}
resources:
{{- toYaml .Values.app.resources | nindent 10 }}
{{- if .Values.storage.enabled }}
volumeMounts:
- name: shared-data
mountPath: /data
{{- end }}
{{- if .Values.storage.enabled }}
volumes:
- name: shared-data
persistentVolumeClaim:
claimName: {{ include "gitdata.fullname" . }}-shared-data
{{- end }}
{{- with .Values.app.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "gitdata.fullname" . }}-app
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: {{ .Values.app.service.type }}
ports:
- port: {{ .Values.app.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,51 @@
{{- /* Application configuration - non-sensitive values */ -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "gitdata.fullname" . }}-config
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
data:
APP_NAME: {{ .Values.app.name | default "gitdata" | quote }}
APP_VERSION: {{ .Chart.AppVersion | quote }}
APP_STATIC_DOMAIN: {{ .Values.config.staticDomain | default "" | quote }}
APP_MEDIA_DOMAIN: {{ .Values.config.mediaDomain | default "" | quote }}
APP_GIT_HTTP_DOMAIN: {{ .Values.config.gitHttpDomain | default "" | quote }}
APP_AVATAR_PATH: {{ .Values.config.avatarPath | default "/data/avatar" | quote }}
APP_REPOS_ROOT: {{ .Values.config.reposRoot | default "/data/repos" | quote }}
APP_LOG_LEVEL: {{ .Values.config.logLevel | default "info" | quote }}
APP_LOG_FORMAT: {{ .Values.config.logFormat | default "json" | quote }}
APP_LOG_FILE_ENABLED: {{ .Values.config.logFileEnabled | default "false" | quote }}
APP_LOG_FILE_PATH: {{ .Values.config.logFilePath | default "/var/log/gitdata/app.log" | quote }}
APP_LOG_FILE_ROTATION: {{ .Values.config.logFileRotation | default "daily" | quote }}
APP_LOG_FILE_MAX_FILES: {{ .Values.config.logFileMaxFiles | default "7" | quote }}
APP_LOG_FILE_MAX_SIZE: {{ .Values.config.logFileMaxSize | default "100" | quote }}
APP_OTEL_ENABLED: {{ .Values.config.otelEnabled | default "false" | quote }}
APP_OTEL_ENDPOINT: {{ .Values.config.otelEndpoint | default "" | quote }}
APP_OTEL_SERVICE_NAME: {{ .Values.config.otelServiceName | default "gitdata" | quote }}
APP_OTEL_SERVICE_VERSION: {{ .Chart.AppVersion | quote }}
APP_DATABASE_MAX_CONNECTIONS: {{ .Values.config.databaseMaxConnections | default "100" | quote }}
APP_DATABASE_MIN_CONNECTIONS: {{ .Values.config.databaseMinConnections | default "5" | quote }}
APP_DATABASE_IDLE_TIMEOUT: {{ .Values.config.databaseIdleTimeout | default "600" | quote }}
APP_DATABASE_MAX_LIFETIME: {{ .Values.config.databaseMaxLifetime | default "3600" | quote }}
APP_DATABASE_CONNECTION_TIMEOUT: {{ .Values.config.databaseConnectionTimeout | default "30" | quote }}
APP_DATABASE_SCHEMA_SEARCH_PATH: {{ .Values.config.databaseSchemaSearchPath | default "public" | quote }}
APP_DATABASE_HEALTH_CHECK_INTERVAL: {{ .Values.config.databaseHealthCheckInterval | default "30" | quote }}
APP_DATABASE_RETRY_ATTEMPTS: {{ .Values.config.databaseRetryAttempts | default "3" | quote }}
APP_DATABASE_RETRY_DELAY: {{ .Values.config.databaseRetryDelay | default "1" | quote }}
APP_REDIS_POOL_SIZE: {{ .Values.config.redisPoolSize | default "16" | quote }}
APP_REDIS_CONNECT_TIMEOUT: {{ .Values.config.redisConnectTimeout | default "5" | quote }}
APP_REDIS_ACQUIRE_TIMEOUT: {{ .Values.config.redisAcquireTimeout | default "1" | quote }}
HOOK_POOL_MAX_CONCURRENT: {{ .Values.config.hookPoolMaxConcurrent | default "100" | quote }}
HOOK_POOL_CPU_THRESHOLD: {{ .Values.config.hookPoolCpuThreshold | default "80" | quote }}
HOOK_POOL_REDIS_LIST_PREFIX: {{ .Values.config.hookPoolRedisListPrefix | default "{hook}" | quote }}
HOOK_POOL_REDIS_LOG_CHANNEL: {{ .Values.config.hookPoolRedisLogChannel | default "hook:logs" | quote }}
HOOK_POOL_REDIS_BLOCK_TIMEOUT: {{ .Values.config.hookPoolRedisBlockTimeout | default "5" | quote }}
HOOK_POOL_REDIS_MAX_RETRIES: {{ .Values.config.hookPoolRedisMaxRetries | default "3" | quote }}
APP_SMTP_PORT: {{ .Values.config.smtpPort | default "587" | quote }}
APP_SMTP_TLS: {{ .Values.config.smtpTls | default "true" | quote }}
APP_SMTP_TIMEOUT: {{ .Values.config.smtpTimeout | default "30" | quote }}
APP_SSH_PORT: {{ .Values.config.sshPort | default "22" | quote }}

View File

@ -0,0 +1,88 @@
{{- if .Values.emailWorker.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gitdata.fullname" . }}-email-worker
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-email-worker
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: email-worker
image: "{{ .Values.image.registry }}/{{ .Values.emailWorker.image.repository }}:{{ .Values.emailWorker.image.tag }}"
imagePullPolicy: {{ .Values.emailWorker.image.pullPolicy | default .Values.image.pullPolicy }}
env:
- name: APP_DATABASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_DATABASE_URL
optional: true
- name: APP_REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_REDIS_URL
optional: true
{{- range .Values.emailWorker.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml .Values.emailWorker.resources | nindent 10 }}
{{- if .Values.emailWorker.livenessProbe }}
livenessProbe:
exec:
command:
{{- range .Values.emailWorker.livenessProbe.exec.command }}
- {{ . | quote }}
{{- end }}
initialDelaySeconds: {{ .Values.emailWorker.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.emailWorker.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.emailWorker.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.emailWorker.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.emailWorker.readinessProbe }}
readinessProbe:
exec:
command:
{{- range .Values.emailWorker.readinessProbe.exec.command }}
- {{ . | quote }}
{{- end }}
initialDelaySeconds: {{ .Values.emailWorker.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.emailWorker.readinessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.emailWorker.readinessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.emailWorker.readinessProbe.failureThreshold }}
{{- end }}
{{- with .Values.emailWorker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.emailWorker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.emailWorker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.storage.enabled }}
volumes:
- name: shared-data
persistentVolumeClaim:
claimName: {{ include "gitdata.fullname" . }}-shared-data
{{- end }}
{{- end }}

View File

@ -0,0 +1,76 @@
{{- /*
External Secrets - 从外部 Secret Manager 同步密钥
需要集群安装: External Secrets Operator (ESO)
https://external-secrets.io/
*/ -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- /* Database Secret */ -}}
{{- if .Values.database.existingSecret -}}
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Values.database.existingSecret }}
namespace: {{ $ns }}
spec:
refreshInterval: 1h
secretStoreRef:
name: {{ .Values.externalSecrets.storeName | default "vault-backend" }}
kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }}
target:
name: {{ .Values.database.existingSecret }}
creationPolicy: Owner
data:
- secretKey: {{ .Values.database.secretKeys.url }}
remoteRef:
key: {{ .Values.externalSecrets.databaseKey | default "gitdata/database" }}
property: url
{{- end }}
{{- /* Redis Secret */ -}}
{{- if .Values.redis.existingSecret -}}
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Values.redis.existingSecret }}
namespace: {{ $ns }}
spec:
refreshInterval: 1h
secretStoreRef:
name: {{ .Values.externalSecrets.storeName | default "vault-backend" }}
kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }}
target:
name: {{ .Values.redis.existingSecret }}
creationPolicy: Owner
data:
- secretKey: {{ .Values.redis.secretKeys.url }}
remoteRef:
key: {{ .Values.externalSecrets.redisKey | default "gitdata/redis" }}
property: url
{{- end }}
{{- /* Qdrant Secret */ -}}
{{- if and .Values.qdrant.enabled .Values.qdrant.existingSecret -}}
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: {{ .Values.qdrant.existingSecret }}
namespace: {{ $ns }}
spec:
refreshInterval: 1h
secretStoreRef:
name: {{ .Values.externalSecrets.storeName | default "vault-backend" }}
kind: {{ .Values.externalSecrets.storeKind | default "SecretStore" }}
target:
name: {{ .Values.qdrant.existingSecret }}
creationPolicy: Owner
data:
- secretKey: {{ .Values.qdrant.secretKeys.apiKey }}
remoteRef:
key: {{ .Values.externalSecrets.qdrantKey | default "gitdata/qdrant" }}
property: apiKey
{{- end }}

View File

@ -0,0 +1,82 @@
{{- if .Values.frontend.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $svc := .Values.frontend -}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: frontend
image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}"
imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: 80
protocol: TCP
resources:
{{- toYaml $svc.resources | nindent 10 }}
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: {{ $svc.service.type }}
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP
selector:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.frontend.ingress.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $ing := .Values.frontend.ingress -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}-frontend
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-frontend
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }}
{{- if $ing.annotations }}
{{ toYaml $ing.annotations | indent 4 }}
{{- end }}
{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }}
{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }}
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
{{- end }}
{{- end }}
spec:
{{- if $ing.className }}
ingressClassName: {{ $ing.className }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else }}
tls:
{{- range $ing.hosts }}
- hosts:
- {{ .host | quote }}
secretName: {{ $fullName }}-frontend-tls
{{- end }}
{{- end }}
rules:
{{- range $ing.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}-frontend
port:
number: 80
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,100 @@
{{- if .Values.gitHook.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gitdata.fullname" . }}-git-hook
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ .Values.gitHook.replicaCount | default 2 }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: git-hook
image: "{{ .Values.image.registry }}/{{ .Values.gitHook.image.repository }}:{{ .Values.gitHook.image.tag }}"
imagePullPolicy: {{ .Values.gitHook.image.pullPolicy | default .Values.image.pullPolicy }}
env:
- name: APP_DATABASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_DATABASE_URL
optional: true
- name: APP_REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_REDIS_URL
optional: true
- name: HOOK_POOL_REDIS_LIST_PREFIX
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: HOOK_POOL_REDIS_LIST_PREFIX
optional: true
- name: HOOK_POOL_REDIS_LOG_CHANNEL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: HOOK_POOL_REDIS_LOG_CHANNEL
optional: true
{{- range .Values.gitHook.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml .Values.gitHook.resources | nindent 10 }}
{{- if .Values.gitHook.livenessProbe }}
livenessProbe:
exec:
command:
{{- range .Values.gitHook.livenessProbe.exec.command }}
- {{ . | quote }}
{{- end }}
initialDelaySeconds: {{ .Values.gitHook.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.gitHook.livenessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.gitHook.livenessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.gitHook.livenessProbe.failureThreshold }}
{{- end }}
{{- if .Values.gitHook.readinessProbe }}
readinessProbe:
exec:
command:
{{- range .Values.gitHook.readinessProbe.exec.command }}
- {{ . | quote }}
{{- end }}
initialDelaySeconds: {{ .Values.gitHook.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ .Values.gitHook.readinessProbe.periodSeconds }}
timeoutSeconds: {{ .Values.gitHook.readinessProbe.timeoutSeconds }}
failureThreshold: {{ .Values.gitHook.readinessProbe.failureThreshold }}
{{- end }}
{{- with .Values.gitHook.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.gitHook.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.gitHook.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.storage.enabled }}
volumes:
- name: shared-data
persistentVolumeClaim:
claimName: {{ include "gitdata.fullname" . }}-shared-data
{{- end }}
{{- end }}

View File

@ -0,0 +1,186 @@
{{- if .Values.gitserver.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $svc := .Values.gitserver -}}
{{/* Uses shared PVC defined in storage.yaml */}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}-gitserver
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
app.kubernetes.io/version: {{ $.Chart.AppVersion }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
spec:
containers:
- name: gitserver
image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}"
imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ $svc.service.http.port }}
protocol: TCP
- name: ssh
containerPort: {{ $svc.service.ssh.port }}
protocol: TCP
env:
- name: APP_REPOS_ROOT
value: /data/repos
- name: APP_DATABASE_URL
valueFrom:
configMapKeyRef:
name: {{ $fullName }}-config
key: APP_DATABASE_URL
optional: true
- name: APP_REDIS_URL
valueFrom:
configMapKeyRef:
name: {{ $fullName }}-config
key: APP_REDIS_URL
optional: true
- name: APP_SSH_DOMAIN
valueFrom:
configMapKeyRef:
name: {{ $fullName }}-config
key: APP_SSH_DOMAIN
optional: true
- name: APP_SSH_PORT
valueFrom:
configMapKeyRef:
name: {{ $fullName }}-config
key: APP_SSH_PORT
optional: true
{{- range $svc.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml $svc.resources | nindent 10 }}
{{- if $svc.livenessProbe }}
livenessProbe:
tcpSocket:
port: {{ $svc.livenessProbe.tcpSocket.port }}
initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $svc.livenessProbe.timeoutSeconds }}
failureThreshold: {{ $svc.livenessProbe.failureThreshold }}
{{- end }}
{{- if $svc.readinessProbe }}
readinessProbe:
tcpSocket:
port: {{ $svc.readinessProbe.tcpSocket.port }}
initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
timeoutSeconds: {{ $svc.readinessProbe.timeoutSeconds }}
failureThreshold: {{ $svc.readinessProbe.failureThreshold }}
{{- end }}
volumeMounts:
{{- if and $svc.persistence.enabled $.Values.storage.enabled }}
- name: shared-data
mountPath: /data/repos
subPath: repos/
{{- end }}
volumes:
{{- if and $svc.persistence.enabled $.Values.storage.enabled }}
- name: shared-data
persistentVolumeClaim:
claimName: {{ $fullName }}-shared-data
{{- end }}
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
# HTTP service (git smart HTTP)
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-gitserver-http
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
spec:
type: {{ $svc.service.http.type }}
ports:
- name: http
port: {{ $svc.service.http.port }}
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
---
# SSH service (git over SSH)
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-gitserver-ssh
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
{{- if $svc.service.ssh.loadBalancerSourceRanges }}
annotations:
metallb.universe.tf/loadBalancerIPs: {{ $svc.service.ssh.loadBalancerIP | default "" }}
{{- end }}
spec:
type: {{ $svc.service.ssh.type }}
{{- if eq $svc.service.ssh.type "LoadBalancer" }}
ports:
- name: ssh
port: {{ $svc.service.ssh.port }}
targetPort: ssh
protocol: TCP
{{- if $svc.service.ssh.loadBalancerIP }}
loadBalancerIP: {{ $svc.service.ssh.loadBalancerIP }}
{{- end }}
{{- if $svc.service.ssh.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- range $svc.service.ssh.loadBalancerSourceRanges }}
- {{ . }}
{{- end }}
{{- end }}
{{- else if eq $svc.service.ssh.type "NodePort" }}
ports:
- name: ssh
port: {{ $svc.service.ssh.port }}
targetPort: ssh
nodePort: {{ $svc.service.ssh.nodePort | default 30222 }}
protocol: TCP
{{- else }}
ports:
- name: ssh
port: {{ $svc.service.ssh.port }}
targetPort: ssh
protocol: TCP
{{- end }}
selector:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ $.Release.Name }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.gitserver.ingress.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $ing := .Values.gitserver.ingress -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}-gitserver-http
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-gitserver
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }}
{{- if $ing.annotations }}
{{ toYaml $ing.annotations | indent 4 }}
{{- end }}
{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }}
{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }}
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
{{- end }}
{{- end }}
spec:
{{- if $ing.className }}
ingressClassName: {{ $ing.className }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else }}
tls:
{{- range $ing.hosts }}
- hosts:
- {{ .host | quote }}
secretName: {{ $fullName }}-gitserver-tls
{{- end }}
{{- end }}
rules:
{{- range $ing.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}-gitserver-http
port:
number: {{ $.Values.gitserver.service.http.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.app.ingress.enabled -}}
{{- $svcName := printf "%s-app" (include "gitdata.fullname" .) -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $ing := .Values.app.ingress -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "gitdata.fullname" . }}-ingress
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }}
{{- if $ing.annotations }}
{{ toYaml $ing.annotations | indent 4 }}
{{- end }}
{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }}
{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }}
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
{{- end }}
{{- end }}
spec:
{{- if $ing.className }}
ingressClassName: {{ $ing.className }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else }}
tls:
{{- range $ing.hosts }}
- hosts:
- {{ .host | quote }}
secretName: {{ include "gitdata.fullname" $ }}-app-tls
{{- end }}
{{- end }}
rules:
{{- range $ing.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $svcName }}
port:
number: {{ $.Values.app.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,42 @@
{{- if .Values.migrate.enabled -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "gitdata.fullname" . }}-migrate
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-migrate
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
helm.sh/hook: post-install,post-upgrade
helm.sh/hook-delete-policy: before-hook-creation
spec:
backoffLimit: {{ .Values.migrate.backoffLimit }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-migrate
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: "{{ .Values.image.registry }}/{{ .Values.migrate.image.repository }}:{{ .Values.migrate.image.tag }}"
imagePullPolicy: {{ .Values.migrate.image.pullPolicy | default .Values.image.pullPolicy }}
command:
{{- if .Values.migrate.command }}
- {{ .Values.migrate.command }}
{{- else }}
- up
{{- end }}
env:
- name: APP_DATABASE_URL
valueFrom:
configMapKeyRef:
name: {{ include "gitdata.fullname" . }}-config
key: APP_DATABASE_URL
{{- range .Values.migrate.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,10 @@
{{- /* Unified namespace declaration */ -}}
apiVersion: v1
kind: Namespace
metadata:
name: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
helm.sh/resource-policy: keep

View File

@ -0,0 +1,116 @@
{{- if .Values.operator.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "gitdata.fullname" . }}-operator
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
serviceAccountName: {{ include "gitdata.fullname" . }}-operator
terminationGracePeriodSeconds: 10
volumes:
- name: tmp
emptyDir: {}
containers:
- name: operator
image: "{{ .Values.image.registry }}/{{ .Values.operator.image.repository }}:{{ .Values.operator.image.tag }}"
imagePullPolicy: {{ .Values.operator.image.pullPolicy | default .Values.image.pullPolicy }}
env:
- name: OPERATOR_IMAGE_PREFIX
value: {{ .Values.operator.imagePrefix | default (printf "%s/" (include "gitdata.fullname" .)) | quote }}
- name: OPERATOR_LOG_LEVEL
value: {{ .Values.operator.logLevel | default "info" | quote }}
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
resources:
{{- toYaml .Values.operator.resources | nindent 10 }}
volumeMounts:
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
{{- with .Values.operator.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operator.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.operator.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "gitdata.fullname" . }}-operator
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: {{ include "gitdata.fullname" . }}-operator
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
rules:
- apiGroups: ["code.dev"]
resources: ["apps", "gitservers", "emailworkers", "githooks", "migrates"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["code.dev"]
resources: ["apps/status", "gitservers/status", "emailworkers/status", "githooks/status", "migrates/status"]
verbs: ["get", "patch", "update"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services", "persistentvolumeclaims", "configmaps", "secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "deletecollection"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: {{ include "gitdata.fullname" . }}-operator
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-operator
app.kubernetes.io/instance: {{ .Release.Name }}
subjects:
- kind: ServiceAccount
name: {{ include "gitdata.fullname" . }}-operator
namespace: {{ include "gitdata.namespace" . }}
roleRef:
kind: Role
name: {{ include "gitdata.fullname" . }}-operator
apiGroup: rbac.authorization.k8s.io
{{- end }}

48
deploy/templates/pdb.yaml Normal file
View File

@ -0,0 +1,48 @@
{{- /* PodDisruptionBudgets for high-availability services */ -}}
{{- if and .Values.app.enabled .Values.app.pdb.enabled -}}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "gitdata.fullname" . }}-app
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- if .Values.app.pdb.minAvailable }}
minAvailable: {{ .Values.app.pdb.minAvailable }}
{{- else if .Values.app.pdb.maxUnavailable }}
maxUnavailable: {{ .Values.app.pdb.maxUnavailable }}
{{- else }}
minAvailable: 1
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.gitHook.enabled .Values.gitHook.pdb.enabled -}}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "gitdata.fullname" . }}-git-hook
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- if .Values.gitHook.pdb.minAvailable }}
minAvailable: {{ .Values.gitHook.pdb.minAvailable }}
{{- else if .Values.gitHook.pdb.maxUnavailable }}
maxUnavailable: {{ .Values.gitHook.pdb.maxUnavailable }}
{{- else }}
minAvailable: 1
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}-git-hook
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,63 @@
{{- /*
Bootstrap secrets for development only.
In production, use an external secret manager (Vault, SealedSecrets, External Secrets).
*/ -}}
{{- /*
Bootstrap secrets for development only.
In production, use an external secret manager (Vault, SealedSecrets, External Secrets).
*/ -}}
{{- $secrets := .Values.secrets | default dict -}}
{{- if $secrets.create -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "gitdata.fullname" . }}-secrets
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
"helm.sh/resource-policy": keep
type: Opaque
stringData:
{{- if $secrets.databaseUrl }}
APP_DATABASE_URL: {{ $secrets.databaseUrl | quote }}
{{- end }}
{{- if $secrets.redisUrl }}
APP_REDIS_URL: {{ $secrets.redisUrl | quote }}
{{- end }}
{{- if .Values.nats.enabled }}
NATS_URL: {{ .Values.nats.url | quote }}
{{- end }}
{{- if $secrets.aiApiKey }}
APP_AI_BASIC_URL: {{ $secrets.aiBasicUrl | quote }}
APP_AI_API_KEY: {{ $secrets.aiApiKey | quote }}
{{- end }}
{{- if $secrets.embedApiKey }}
APP_EMBED_MODEL_BASE_URL: {{ $secrets.embedBasicUrl | quote }}
APP_EMBED_MODEL_API_KEY: {{ $secrets.embedApiKey | quote }}
APP_EMBED_MODEL_NAME: {{ $secrets.embedModelName | quote }}
{{- end }}
{{- if and .Values.qdrant.enabled .Values.qdrant.url }}
APP_QDRANT_URL: {{ .Values.qdrant.url | quote }}
{{- end }}
{{- if .Values.qdrant.apiKey }}
APP_QDRANT_API_KEY: {{ .Values.qdrant.apiKey | quote }}
{{- end }}
{{- if $secrets.smtpHost }}
APP_SMTP_HOST: {{ $secrets.smtpHost | quote }}
APP_SMTP_PORT: {{ $secrets.smtpPort | default "587" | quote }}
APP_SMTP_USERNAME: {{ $secrets.smtpUsername | quote }}
APP_SMTP_PASSWORD: {{ $secrets.smtpPassword | quote }}
APP_SMTP_FROM: {{ $secrets.smtpFrom | default $secrets.smtpUsername | quote }}
APP_SMTP_TLS: {{ $secrets.smtpTls | default "true" | quote }}
{{- end }}
{{- if $secrets.sshDomain }}
APP_SSH_DOMAIN: {{ $secrets.sshDomain | quote }}
{{- end }}
{{- range $key, $value := $secrets.extra | default dict }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,112 @@
{{- if .Values.static.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $svc := .Values.static -}}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ $fullName }}-static
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion }}
spec:
replicas: {{ $svc.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
containers:
- name: static
image: "{{ $.Values.image.registry }}/{{ $svc.image.repository }}:{{ $svc.image.tag }}"
imagePullPolicy: {{ $svc.image.pullPolicy | default $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ $svc.service.port }}
protocol: TCP
env:
- name: STATIC_ROOT
value: /data
- name: STATIC_BIND
value: {{ printf "0.0.0.0:%s" (print $svc.service.port) }}
- name: STATIC_CORS
value: {{ $svc.cors | default "true" | quote }}
{{- if $svc.logLevel }}
- name: STATIC_LOG_LEVEL
value: {{ $svc.logLevel }}
{{- end }}
{{- range $svc.env }}
- name: {{ .name }}
value: {{ .value | quote }}
{{- end }}
resources:
{{- toYaml $svc.resources | nindent 10 }}
{{- if $svc.livenessProbe }}
livenessProbe:
httpGet:
path: /health
port: {{ $svc.service.port }}
initialDelaySeconds: {{ $svc.livenessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.livenessProbe.periodSeconds }}
timeoutSeconds: {{ $svc.livenessProbe.timeoutSeconds }}
failureThreshold: {{ $svc.livenessProbe.failureThreshold }}
{{- end }}
{{- if $svc.readinessProbe }}
readinessProbe:
httpGet:
path: /health
port: {{ $svc.service.port }}
initialDelaySeconds: {{ $svc.readinessProbe.initialDelaySeconds }}
periodSeconds: {{ $svc.readinessProbe.periodSeconds }}
timeoutSeconds: {{ $svc.readinessProbe.timeoutSeconds }}
failureThreshold: {{ $svc.readinessProbe.failureThreshold }}
{{- end }}
volumeMounts:
- name: shared-data
mountPath: /data
volumes:
- name: shared-data
persistentVolumeClaim:
claimName: {{ $fullName }}-shared-data
{{- with $svc.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with $svc.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ $fullName }}-static
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
type: {{ $svc.service.type }}
ports:
- name: http
port: {{ $svc.service.port }}
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

View File

@ -0,0 +1,61 @@
{{- if .Values.static.ingress.enabled -}}
{{- $fullName := include "gitdata.fullname" . -}}
{{- $ns := include "gitdata.namespace" . -}}
{{- $ing := .Values.static.ingress -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ $fullName }}-static
namespace: {{ $ns }}
labels:
app.kubernetes.io/name: {{ $fullName }}-static
app.kubernetes.io/instance: {{ .Release.Name }}
annotations:
cert-manager.io/cluster-issuer: {{ $ing.clusterIssuer | default "cloudflare-acme-cluster-issuer" }}
{{- if $ing.annotations }}
{{ toYaml $ing.annotations | indent 4 }}
{{- end }}
{{- if not (hasKey ($ing.annotations | default dict) "nginx.ingress.kubernetes.io/proxy-body-size") }}
{{- if or (not $ing.className) (eq $ing.className "nginx") (contains "nginx" $ing.className) }}
nginx.ingress.kubernetes.io/proxy-body-size: "0"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
{{- end }}
{{- end }}
spec:
{{- if $ing.className }}
ingressClassName: {{ $ing.className }}
{{- end }}
{{- if $ing.tls }}
tls:
{{- range $ing.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- else }}
tls:
{{- range $ing.hosts }}
- hosts:
- {{ .host | quote }}
secretName: {{ $fullName }}-static-tls
{{- end }}
{{- end }}
rules:
{{- range $ing.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType | default "Prefix" }}
backend:
service:
name: {{ $fullName }}-static
port:
number: {{ $.Values.static.service.port }}
{{- end }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,18 @@
{{- /* Global shared PVC for all components */ -}}
{{- if .Values.storage.enabled -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "gitdata.fullname" . }}-shared-data
namespace: {{ include "gitdata.namespace" . }}
labels:
app.kubernetes.io/name: {{ include "gitdata.fullname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
accessModes:
- {{ .Values.storage.accessMode | default "ReadWriteMany" }}
storageClassName: {{ .Values.storage.storageClass }}
resources:
requests:
storage: {{ .Values.storage.size }}
{{- end }}

View File

@ -0,0 +1,45 @@
# User overrides for Helm deployment
# Copy to values.user.yaml and customize
# PostgreSQL
database:
existingSecret: gitdata-secrets
secretKeys:
url: APP_DATABASE_URL
# Redis
redis:
existingSecret: gitdata-secrets
secretKeys:
url: APP_REDIS_URL
# NATS (if using hook pool)
nats:
enabled: false
url: nats://nats:4222
# Qdrant (if using AI embeddings)
qdrant:
enabled: false
url: http://qdrant:6333
# App configuration
app:
replicaCount: 3
ingress:
enabled: true
hosts:
- host: git.example.com
# Gitserver
gitserver:
persistence:
size: 100Gi
storageClass: fast-ssd
ingress:
enabled: true
hosts:
- host: git-http.example.com
annotations:
# Override default proxy-body-size if needed
# nginx.ingress.kubernetes.io/proxy-body-size: "0" # 0 = unlimited

497
deploy/values.yaml Normal file
View File

@ -0,0 +1,497 @@
# =============================================================================
# Global / common settings
# =============================================================================
namespace: gitdataai
releaseName: gitdata
image:
registry: harbor.gitdata.me/gta_team
pullPolicy: IfNotPresent
# =============================================================================
# Cert-Manager Configuration (集群已安装 cert-manager)
# =============================================================================
certManager:
enabled: true
clusterIssuerName: cloudflare-acme-cluster-issuer # 引用集群已有的 ClusterIssuer
# =============================================================================
# External Secrets Configuration (需要集群安装 ESO)
# =============================================================================
externalSecrets:
storeName: "vault-backend"
storeKind: "SecretStore"
databaseKey: "gitdata/database"
redisKey: "gitdata/redis"
qdrantKey: "gitdata/qdrant"
# =============================================================================
# Shared persistent storage (aliyun-nfs)
# =============================================================================
storage:
enabled: true
storageClass: aliyun-nfs
size: 20Ti
accessMode: ReadWriteMany # NFS supports multiple readers/writers
# =============================================================================
# Application config (non-sensitive, shared via ConfigMap)
# =============================================================================
config:
# App info
name: gitdata
# Domain configuration
staticDomain: "https://static.gitdata.ai"
mediaDomain: ""
gitHttpDomain: "https://git.gitdata.ai"
# Storage paths
avatarPath: /data/avatar
reposRoot: /data/repos
# Logging
logLevel: info
logFormat: json
logFileEnabled: "false"
logFilePath: /var/log/gitdata/app.log
logFileRotation: daily
logFileMaxFiles: "7"
logFileMaxSize: "100"
# OpenTelemetry
otelEnabled: "false"
otelEndpoint: ""
otelServiceName: gitdata
# Database pool tuning
databaseMaxConnections: "100"
databaseMinConnections: "5"
databaseIdleTimeout: "600"
databaseMaxLifetime: "3600"
databaseConnectionTimeout: "30"
databaseSchemaSearchPath: public
databaseHealthCheckInterval: "30"
databaseRetryAttempts: "3"
databaseRetryDelay: "1"
# Redis tuning
redisPoolSize: "16"
redisConnectTimeout: "5"
redisAcquireTimeout: "1"
# Hook pool
hookPoolMaxConcurrent: "100"
hookPoolCpuThreshold: "80"
hookPoolRedisListPrefix: "{hook}"
hookPoolRedisLogChannel: hook:logs
hookPoolRedisBlockTimeout: "5"
hookPoolRedisMaxRetries: "3"
# SSH
sshPort: "22"
# SMTP (non-sensitive defaults)
smtpPort: "465"
smtpTls: "true"
smtpTimeout: "30"
# PostgreSQL (required)
database:
existingSecret: "" # 留空则使用默认名 {release-name}-secrets
secretKeys:
url: APP_DATABASE_URL
# Redis (required)
redis:
existingSecret: ""
secretKeys:
url: APP_REDIS_URL
# NATS (optional)
nats:
enabled: true
url: "nats://nats-client.nats.svc.cluster.local:4222"
# Qdrant (optional)
qdrant:
enabled: true
url: "http://qdrant.qdrant.svc.cluster.local:6333"
existingSecret: ""
secretKeys:
apiKey: APP_QDRANT_API_KEY
# =============================================================================
# Frontend - React SPA
# =============================================================================
frontend:
enabled: true
replicaCount: 2
image:
repository: frontend
tag: latest
service:
type: ClusterIP
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: gitdata.ai
paths:
- path: /
pathType: Prefix
tls: []
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
livenessProbe:
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
initialDelaySeconds: 5
periodSeconds: 5
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# App main web/API service
# =============================================================================
app:
enabled: true
replicaCount: 3
image:
repository: app
tag: latest
# Pod disruption budget
pdb:
enabled: true
minAvailable: 2 # Keep at least 2 pods available during disruptions
service:
type: ClusterIP
port: 8080
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: gitdata.ai
paths:
- path: /
pathType: Prefix
- path: /api
pathType: Prefix
tls: []
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
livenessProbe:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
startupProbe:
path: /health
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 30 # Allow up to 5 minutes for slow starts
env: []
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# Static server - avatar, blob, media files
# =============================================================================
static:
enabled: true
replicaCount: 2
image:
repository: static
tag: latest
service:
type: ClusterIP
port: 8081
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: static.gitdata.ai
paths:
- path: /
pathType: Prefix
cors: true
logLevel: info
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
livenessProbe:
path: /health
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
path: /health
port: 8081
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
env: []
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# Gitserver git daemon / SSH + HTTP server
# =============================================================================
gitserver:
enabled: true
replicaCount: 1
image:
repository: gitserver
tag: latest
service:
http:
type: ClusterIP
port: 8022
ssh:
type: LoadBalancer
port: 22
domain: ""
loadBalancerIP: ""
loadBalancerSourceRanges: []
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
tcpSocket:
port: 8022
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
tcpSocket:
port: 8022
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
persistence:
enabled: true
storageClass: ""
size: 50Gi
accessMode: ReadWriteOnce
ingress:
enabled: true
className: nginx
annotations: {}
hosts:
- host: git.gitdata.ai
paths:
- path: /
pathType: Prefix
tls: []
env: []
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# Email worker processes outgoing email queue
# =============================================================================
emailWorker:
enabled: true
image:
repository: email-worker
tag: latest
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep email-worker || exit 1"
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep email-worker || exit 1"
initialDelaySeconds: 5
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
env: []
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# Git hook pool handles pre-receive / post-receive hooks
# =============================================================================
gitHook:
enabled: true
image:
repository: git-hook
tag: latest
replicaCount: 2
pdb:
enabled: true
minAvailable: 1
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep git-hook || exit 1"
initialDelaySeconds: 10
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep git-hook || exit 1"
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
env: []
nodeSelector: {}
tolerations: []
affinity: {}
# =============================================================================
# Migrate database migration Job (runOnce)
# =============================================================================
migrate:
enabled: false # Set true to run migrations on upgrade
image:
repository: migrate
tag: latest
command: up
backoffLimit: 3
env: []
# =============================================================================
# Operator Kubernetes operator
# =============================================================================
operator:
enabled: false
image:
repository: operator
tag: latest
imagePrefix: ""
logLevel: info
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
nodeSelector: {}
tolerations: []
affinity: {}

38
docker/app.Dockerfile Normal file
View File

@ -0,0 +1,38 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
# Build dependencies: OpenSSL, libgit2, zlib, clang for sea-orm codegen
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy workspace manifests
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
# Pre-build dependencies only
RUN cargo fetch
# Build the binary
RUN cargo build --release --package app --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/app /app/app
# All config via environment variables (APP_* prefix)
ENV APP_LOG_LEVEL=info
ENTRYPOINT ["/app/app"]

171
docker/build.md Normal file
View File

@ -0,0 +1,171 @@
# Docker 构建指南
## 前提条件
- Docker 20.10+
- Cargo.lock 已存在(`cargo generate-lockfile`
- 网络能够访问 crates.io
## 快速开始
```bash
# 构建全部镜像(默认 registry=myapp, tag=latest
./docker/build.sh
# 构建指定镜像
./docker/build.sh app
./docker/build.sh gitserver email-worker
# 指定 registry 和 tag
REGISTRY=myregistry TAG=v1.0.0 ./docker/build.sh
```
## 镜像列表
| 镜像 | Dockerfile | 二进制 | 实例类型 | 说明 |
|---|---|---|---|---|
| `myapp/app:latest` | `app.Dockerfile` | `app` | 多实例 | 主 Web 服务API + WS |
| `myapp/gitserver:latest` | `gitserver.Dockerfile` | `gitserver` | 单实例 | Git HTTP + SSH 服务 |
| `myapp/email-worker:latest` | `email-worker.Dockerfile` | `email-worker` | 单实例 | 邮件发送 Worker |
| `myapp/git-hook:latest` | `git-hook.Dockerfile` | `git-hook` | 单实例 | Git Hook 事件处理 |
| `myapp/migrate:latest` | `migrate.Dockerfile` | `migrate` | Job/InitContainer | 数据库迁移 CLI |
## 部署架构
```
┌─ NATS ─┐
│ │
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
│ LB/ │───▶│ app (×N) │ │ git-hook │
│ nginx │ │ (stateless) │ │ (单实例) │
└─────────┘ └──────────────┘ └─────────────────┘
┌──────────────┐
│ gitserver │
│ (单实例) │ ┌─────────────────┐
│ HTTP :8022 │───▶│ email-worker │
│ SSH :2222 │ │ (单实例) │
└──────────────┘ └─────────────────┘
```
## 环境变量
所有配置通过环境变量注入,无需修改镜像:
| 变量 | 示例 | 说明 |
|---|---|---|
| `APP_DATABASE_URL` | `postgres://user:pass@host:5432/db` | 数据库连接 |
| `APP_REDIS_URLS` | `redis://host:6379` | Redis多实例用逗号分隔 |
| `APP_SMTP_HOST` | `smtp.example.com` | SMTP 服务器 |
| `APP_SMTP_USERNAME` | `noreply@example.com` | SMTP 用户名 |
| `APP_SMTP_PASSWORD` | `xxx` | SMTP 密码 |
| `APP_SMTP_FROM` | `noreply@example.com` | 发件人地址 |
| `APP_AI_BASIC_URL` | `https://api.openai.com/v1` | AI API 地址 |
| `APP_AI_API_KEY` | `sk-xxx` | AI API Key |
| `APP_DOMAIN_URL` | `https://example.com` | 主域名 |
| `APP_LOG_LEVEL` | `info` | 日志级别: trace/debug/info/warn/error |
| `APP_SSH_DOMAIN` | `git.example.com` | Git SSH 域名 |
| `APP_REPOS_ROOT` | `/data/repos` | Git 仓库存储路径 |
| `NATS_URL` | `nats://localhost:4222` | NATS 服务器地址 |
## 数据库迁移
镜像启动前先运行迁移:
```bash
# 方式一:直接运行
docker run --rm \
--env-file .env \
myapp/migrate:latest up
# 方式二Kubernetes InitContainer
# 见下方 K8s 示例
```
## Kubernetes 部署示例
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: myapp/app:latest
envFrom:
- secretRef:
name: app-secrets
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitserver
spec:
replicas: 1
template:
spec:
containers:
- name: gitserver
image: myapp/gitserver:latest
ports:
- containerPort: 8022 # HTTP
- containerPort: 2222 # SSH
envFrom:
- secretRef:
name: app-secrets
volumeMounts:
- name: repos
mountPath: /data/repos
volumes:
- name: repos
persistentVolumeClaim:
claimName: git-repos
---
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
spec:
template:
spec:
containers:
- name: migrate
image: myapp/migrate:latest
envFrom:
- secretRef:
name: app-secrets
args: ["up"]
restartPolicy: Never
```
## 构建缓存
使用 Docker BuildKit 的构建缓存:
- `--mount=type=cache,target=/usr/local/cargo/registry` — crates.io 依赖
- `--mount=type=cache,target=/usr/local/cargo/git` — git 依赖
- `--mount=type=cache,target=target` — 编译产物
建议挂载持久化缓存卷以加速增量构建:
```bash
docker buildx create --use
docker buildx build \
--cache-from=type=local,src=/tmp/cargo-cache \
--cache-to=type=local,dest=/tmp/cargo-cache \
-f docker/app.Dockerfile -t myapp/app .
```
## 跨平台构建
默认构建 x86_64 Linux 可执行文件。构建其他平台:
```bash
# ARM64
BUILD_TARGET=aarch64-unknown-linux-gnu ./docker/build.sh
# 需先安装对应 target
rustup target add aarch64-unknown-linux-gnu
```

52
docker/build.sh Normal file
View File

@ -0,0 +1,52 @@
#!/bin/bash
set -e
REGISTRY="${REGISTRY:-harbor.gitdata.me/gta_team}"
TAG="${TAG:-latest}"
BUILD_TARGET="${BUILD_TARGET:-x86_64-unknown-linux-gnu}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR/.."
# All images: (dockerfile, image-name)
declare -A ALL_IMAGES=(
[app]="docker/app.Dockerfile"
[gitserver]="docker/gitserver.Dockerfile"
[email-worker]="docker/email-worker.Dockerfile"
[git-hook]="docker/git-hook.Dockerfile"
[migrate]="docker/migrate.Dockerfile"
[operator]="docker/operator.Dockerfile"
)
# Filter by first argument if provided
TARGETS=("$@")
if [[ ${#TARGETS[@]} -eq 0 ]] || [[ "${TARGETS[0]}" == "all" ]]; then
TARGETS=("${!ALL_IMAGES[@]}")
fi
for name in "${TARGETS[@]}"; do
df="${ALL_IMAGES[$name]}"
if [[ -z "$df" ]]; then
echo "ERROR: unknown image '$name'"
echo "Available: ${!ALL_IMAGES[@]}"
exit 1
fi
if [[ ! -f "$df" ]]; then
echo "ERROR: $df not found"
exit 1
fi
image="${REGISTRY}/${name}:${TAG}"
echo "==> Building $image"
docker build \
--build-arg BUILD_TARGET="${BUILD_TARGET}" \
-f "$df" \
-t "$image" \
.
echo "==> $image done"
echo ""
done
echo "==> All images built:"
for name in "${TARGETS[@]}"; do
echo " ${REGISTRY}/${name}:${TAG}"
done

127
docker/crd/app-crd.yaml Normal file
View File

@ -0,0 +1,127 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: apps.code.dev
annotations:
controller-gen.kubebuilder.io/version: v0.16.0
spec:
group: code.dev
names:
kind: App
listKind: AppList
plural: apps
singular: app
shortNames:
- app
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Replicas
jsonPath: .spec.replicas
type: integer
- name: Ready
jsonPath: .status.phase
type: string
- name: Age
jsonPath: .metadata.creationTimestamp
type: date
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: []
properties:
image:
type: string
default: myapp/app:latest
replicas:
type: integer
default: 3
env:
type: array
items:
type: object
required: [name]
properties:
name:
type: string
value:
type: string
valueFrom:
type: object
properties:
secretRef:
type: object
required: [name, secretName, secretKey]
properties:
name:
type: string
secretName:
type: string
secretKey:
type: string
resources:
type: object
properties:
requests:
type: object
properties:
cpu:
type: string
memory:
type: string
limits:
type: object
properties:
cpu:
type: string
memory:
type: string
livenessProbe:
type: object
properties:
port:
type: integer
default: 8080
path:
type: string
default: /health
initialDelaySeconds:
type: integer
default: 5
readinessProbe:
type: object
properties:
port:
type: integer
default: 8080
path:
type: string
default: /health
initialDelaySeconds:
type: integer
default: 5
imagePullPolicy:
type: string
default: IfNotPresent
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string

View File

@ -0,0 +1,94 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: emailworkers.code.dev
annotations:
controller-gen.kubebuilder.io/version: v0.16.0
spec:
group: code.dev
names:
kind: EmailWorker
listKind: EmailWorkerList
plural: emailworkers
singular: emailworker
shortNames:
- ew
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Age
jsonPath: .metadata.creationTimestamp
type: date
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: []
properties:
image:
type: string
default: myapp/email-worker:latest
env:
type: array
items:
type: object
required: [name]
properties:
name:
type: string
value:
type: string
valueFrom:
type: object
properties:
secretRef:
type: object
required: [name, secretName, secretKey]
properties:
name:
type: string
secretName:
type: string
secretKey:
type: string
resources:
type: object
properties:
requests:
type: object
properties:
cpu:
type: string
memory:
type: string
limits:
type: object
properties:
cpu:
type: string
memory:
type: string
imagePullPolicy:
type: string
default: IfNotPresent
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string

View File

@ -0,0 +1,96 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: githooks.code.dev
annotations:
controller-gen.kubebuilder.io/version: v0.16.0
spec:
group: code.dev
names:
kind: GitHook
listKind: GitHookList
plural: githooks
singular: githook
shortNames:
- ghk
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Age
jsonPath: .metadata.creationTimestamp
type: date
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: []
properties:
image:
type: string
default: myapp/git-hook:latest
env:
type: array
items:
type: object
required: [name]
properties:
name:
type: string
value:
type: string
valueFrom:
type: object
properties:
secretRef:
type: object
required: [name, secretName, secretKey]
properties:
name:
type: string
secretName:
type: string
secretKey:
type: string
resources:
type: object
properties:
requests:
type: object
properties:
cpu:
type: string
memory:
type: string
limits:
type: object
properties:
cpu:
type: string
memory:
type: string
imagePullPolicy:
type: string
default: IfNotPresent
workerId:
type: string
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string

View File

@ -0,0 +1,108 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: gitservers.code.dev
annotations:
controller-gen.kubebuilder.io/version: v0.16.0
spec:
group: code.dev
names:
kind: GitServer
listKind: GitServerList
plural: gitservers
singular: gitserver
shortNames:
- gs
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Age
jsonPath: .metadata.creationTimestamp
type: date
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: []
properties:
image:
type: string
default: myapp/gitserver:latest
env:
type: array
items:
type: object
required: [name]
properties:
name:
type: string
value:
type: string
valueFrom:
type: object
properties:
secretRef:
type: object
required: [name, secretName, secretKey]
properties:
name:
type: string
secretName:
type: string
secretKey:
type: string
resources:
type: object
properties:
requests:
type: object
properties:
cpu:
type: string
memory:
type: string
limits:
type: object
properties:
cpu:
type: string
memory:
type: string
sshServiceType:
type: string
default: NodePort
storageSize:
type: string
default: 10Gi
imagePullPolicy:
type: string
default: IfNotPresent
sshDomain:
type: string
sshPort:
type: integer
default: 22
httpPort:
type: integer
default: 8022
status:
type: object
properties:
readyReplicas:
type: integer
phase:
type: string

View File

@ -0,0 +1,87 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: migrates.code.dev
annotations:
controller-gen.kubebuilder.io/version: v0.16.0
spec:
group: code.dev
names:
kind: Migrate
listKind: MigrateList
plural: migrates
singular: migrate
shortNames:
- mig
scope: Namespaced
versions:
- name: v1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- name: Status
jsonPath: .status.phase
type: string
- name: Age
jsonPath: .metadata.creationTimestamp
type: date
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
required: []
properties:
image:
type: string
default: myapp/migrate:latest
env:
type: array
description: "Must include APP_DATABASE_URL"
items:
type: object
required: [name]
properties:
name:
type: string
value:
type: string
valueFrom:
type: object
properties:
secretRef:
type: object
required: [name, secretName, secretKey]
properties:
name:
type: string
secretName:
type: string
secretKey:
type: string
command:
type: string
default: up
description: "Migration command: up, down, fresh, refresh, reset"
backoffLimit:
type: integer
default: 3
status:
type: object
properties:
phase:
type: string
startTime:
type: string
completionTime:
type: string

View File

@ -0,0 +1,32 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
RUN cargo fetch
RUN cargo build --release --package email-server --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/email-server /app/email-worker
ENV APP_LOG_LEVEL=info
ENTRYPOINT ["/app/email-worker"]

View File

@ -0,0 +1,50 @@
# ---- Stage 1: Build ----
FROM node:22-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare pnpm@10 --activate
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build
RUN pnpm build
# ---- Stage 2: Serve with nginx ----
FROM nginx:alpine AS runtime
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx configuration for SPA
RUN echo 'server { \
listen 80; \
server_name _; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /api/ { \
proxy_pass http://app:8080/api/; \
proxy_set_header Host $host; \
proxy_set_header X-Real-IP $remote_addr; \
} \
location /ws/ { \
proxy_pass http://app:8080/ws/; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,32 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libgit2-dev zlib1g-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
RUN cargo fetch
RUN cargo build --release --package git-hook --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 openssh-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/git-hook /app/git-hook
ENV APP_LOG_LEVEL=info
ENTRYPOINT ["/app/git-hook"]

View File

@ -0,0 +1,37 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libgit2-dev zlib1g-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
RUN cargo fetch
RUN cargo build --release --package gitserver --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 openssh-server \
&& rm -rf /var/lib/apt/lists/*
# SSH requires host keys and proper permissions
RUN mkdir -p /run/sshd && \
ssh-keygen -A && \
chmod 755 /etc/ssh
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/gitserver /app/gitserver
ENV APP_LOG_LEVEL=info
ENTRYPOINT ["/app/gitserver"]

32
docker/migrate.Dockerfile Normal file
View File

@ -0,0 +1,32 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
RUN cargo fetch
RUN cargo build --release --package migrate-cli --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/migrate /app/migrate
# Run migrations via: docker run --rm myapp/migrate up
ENTRYPOINT ["/app/migrate"]

View File

@ -0,0 +1,35 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev libclang-dev \
gcc g++ make \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/ apps/
RUN cargo fetch
RUN cargo build --release --package operator --target ${TARGET} -j $(nproc)
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/operator /app/operator
# The operator reads POD_NAMESPACE and OPERATOR_IMAGE_PREFIX from env.
# It connects to the in-cluster Kubernetes API via the service account token.
# All child resources are created in the operator's own namespace.
ENV OPERATOR_LOG_LEVEL=info
ENTRYPOINT ["/app/operator"]

View File

@ -0,0 +1,128 @@
# ---- Namespace ----
apiVersion: v1
kind: Namespace
metadata:
name: code-system
---
# ---- ServiceAccount ----
apiVersion: v1
kind: ServiceAccount
metadata:
name: code-operator
namespace: code-system
---
# ---- RBAC: Role ----
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: code-operator
namespace: code-system
rules:
# CRDs we manage
- apiGroups: ["code.dev"]
resources: ["apps", "gitservers", "emailworkers", "githooks", "migrates"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Status subresources
- apiGroups: ["code.dev"]
resources: ["apps/status", "gitservers/status", "emailworkers/status", "githooks/status", "migrates/status"]
verbs: ["get", "patch", "update"]
# Child resources managed by App
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Child resources managed by GitServer
- apiGroups: [""]
resources: ["persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Child resources managed by GitHook
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Child resources managed by Migrate
- apiGroups: ["batch"]
resources: ["jobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "deletecollection"]
# Secrets (read-only for env var resolution)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
---
# ---- RBAC: RoleBinding ----
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: code-operator
namespace: code-system
subjects:
- kind: ServiceAccount
name: code-operator
namespace: code-system
roleRef:
kind: Role
name: code-operator
apiGroup: rbac.authorization.k8s.io
---
# ---- Deployment ----
apiVersion: apps/v1
kind: Deployment
metadata:
name: code-operator
namespace: code-system
labels:
app.kubernetes.io/name: code-operator
app.kubernetes.io/managed-by: code-operator
app.kubernetes.io/part-of: code-system
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: code-operator
template:
metadata:
labels:
app.kubernetes.io/name: code-operator
app.kubernetes.io/managed-by: code-operator
app.kubernetes.io/part-of: code-system
spec:
serviceAccountName: code-operator
terminationGracePeriodSeconds: 10
volumes:
- name: tmp
emptyDir: {}
containers:
- name: operator
image: myapp/operator:latest
imagePullPolicy: IfNotPresent
env:
- name: OPERATOR_IMAGE_PREFIX
value: "myapp/"
- name: OPERATOR_LOG_LEVEL
value: "info"
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
resources:
requests:
cpu: 10m
memory: 64Mi
limits:
memory: 256Mi
volumeMounts:
- name: tmp
mountPath: /tmp
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL

View File

@ -0,0 +1,280 @@
# Example: deploying the full code system into `code-system` namespace.
#
# Prerequisites:
# 1. Install CRDs: kubectl apply -f ../crd/
# 2. Install Operator: kubectl apply -f ../operator/deployment.yaml
#
# Then apply this file:
# kubectl apply -f example/code-system.yaml
apiVersion: v1
kind: Secret
metadata:
name: app-secrets
namespace: code-system
type: Opaque
stringData:
APP_DATABASE_URL: "postgres://user:password@postgres:5432/codedb?sslmode=disable"
APP_REDIS_URLS: "redis://redis:6379"
APP_SMTP_HOST: "smtp.example.com"
APP_SMTP_PORT: "587"
APP_SMTP_USERNAME: "noreply@example.com"
APP_SMTP_PASSWORD: "change-me"
APP_SMTP_FROM: "noreply@example.com"
APP_AI_BASIC_URL: "https://api.openai.com/v1"
APP_AI_API_KEY: "sk-change-me"
APP_SSH_SERVER_PRIVATE_KEY: |
-----BEGIN OPENSSH PRIVATE KEY-----
... paste your SSH private key here ...
-----END OPENSSH PRIVATE KEY-----
APP_SSH_SERVER_PUBLIC_KEY: "ssh-ed25519 AAAAC3... your-pub-key"
---
# ---- App (main web service, 3 replicas) ----
apiVersion: code.dev/v1
kind: App
metadata:
name: app
namespace: code-system
spec:
image: myapp/app:latest
replicas: 3
imagePullPolicy: IfNotPresent
env:
- name: APP_DATABASE_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_DATABASE_URL
- name: APP_REDIS_URLS
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_REDIS_URLS
- name: APP_SMTP_HOST
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_HOST
- name: APP_SMTP_USERNAME
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_USERNAME
- name: APP_SMTP_PASSWORD
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_PASSWORD
- name: APP_SMTP_FROM
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_FROM
- name: APP_AI_BASIC_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_AI_BASIC_URL
- name: APP_AI_API_KEY
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_AI_API_KEY
- name: APP_DOMAIN_URL
value: "https://example.com"
- name: APP_LOG_LEVEL
value: "info"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
port: 8080
path: /health
initialDelaySeconds: 10
readinessProbe:
port: 8080
path: /health
initialDelaySeconds: 5
---
# ---- GitServer (git HTTP + SSH, single instance) ----
apiVersion: code.dev/v1
kind: GitServer
metadata:
name: gitserver
namespace: code-system
spec:
image: myapp/gitserver:latest
imagePullPolicy: IfNotPresent
env:
- name: APP_DATABASE_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_DATABASE_URL
- name: APP_REDIS_URLS
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_REDIS_URLS
- name: APP_SSH_SERVER_PRIVATE_KEY
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SSH_SERVER_PRIVATE_KEY
- name: APP_SSH_SERVER_PUBLIC_KEY
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SSH_SERVER_PUBLIC_KEY
- name: APP_SSH_DOMAIN
value: "git.example.com"
- name: APP_REPOS_ROOT
value: "/data/repos"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 1000m
memory: 1Gi
sshServiceType: NodePort # Use LoadBalancer in production
sshPort: 22
httpPort: 8022
storageSize: 50Gi
---
# ---- EmailWorker (single instance) ----
apiVersion: code.dev/v1
kind: EmailWorker
metadata:
name: email-worker
namespace: code-system
spec:
image: myapp/email-worker:latest
imagePullPolicy: IfNotPresent
env:
- name: APP_DATABASE_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_DATABASE_URL
- name: APP_REDIS_URLS
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_REDIS_URLS
- name: APP_SMTP_HOST
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_HOST
- name: APP_SMTP_USERNAME
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_USERNAME
- name: APP_SMTP_PASSWORD
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_PASSWORD
- name: APP_SMTP_FROM
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_SMTP_FROM
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
---
# ---- GitHook (single instance) ----
apiVersion: code.dev/v1
kind: GitHook
metadata:
name: git-hook
namespace: code-system
spec:
image: myapp/git-hook:latest
imagePullPolicy: IfNotPresent
env:
- name: APP_DATABASE_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_DATABASE_URL
- name: APP_REDIS_URLS
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_REDIS_URLS
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi
---
# ---- Migrate (auto-triggered on apply) ----
apiVersion: code.dev/v1
kind: Migrate
metadata:
name: migrate
namespace: code-system
spec:
image: myapp/migrate:latest
command: up
backoffLimit: 3
env:
- name: APP_DATABASE_URL
valueFrom:
secretRef:
name: app-secrets
secretName: app-secrets
secretKey: APP_DATABASE_URL
---
# ---- Ingress (example for App) ----
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
namespace: code-system
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
spec:
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 80

41
docker/static.Dockerfile Normal file
View File

@ -0,0 +1,41 @@
# ---- Stage 1: Build ----
FROM rust:1.94.0-bookworm AS builder
ARG BUILD_TARGET=x86_64-unknown-linux-gnu
ENV TARGET=${BUILD_TARGET}
WORKDIR /build
# Copy workspace manifests
COPY Cargo.toml Cargo.lock ./
COPY libs/ libs/
COPY apps/static/ apps/static/
# Pre-build dependencies only
RUN cargo fetch
# Build the binary
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=target \
cargo build --release --package static-server --target ${TARGET}
# ---- Stage 2: Runtime ----
FROM debian:bookworm-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/${TARGET}/release/static-server /app/static-server
ENV RUST_LOG=info
ENV STATIC_LOG_LEVEL=info
ENV STATIC_BIND=0.0.0.0:8081
ENV STATIC_ROOT=/data
ENV STATIC_CORS=true
EXPOSE 8081
ENTRYPOINT ["/app/static-server"]

903
docs/ARCHITECTURE-LAYERS.md Normal file
View File

@ -0,0 +1,903 @@
# Code 项目架构分层图
> 一个现代化的代码协作与团队沟通平台
>
> 技术栈Rust (后端) + TypeScript/React (前端) + Kubernetes (部署)
---
## 系统全景架构
```
┌─────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 用 户 层 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Web 浏览器 │ │ Git 客户端 │ │ 外部 CI/CD │ │
│ │ (React SPA) │ │ (git/SSH) │ │ (GitHub/GitLab) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
└──────────────────┼────────────────────────────────┼────────────────────────────────┼────────────────┘
│ │ │
│ HTTP/WS │ Git Protocol │ Webhook
│ │ │
┌──────────────────▼────────────────────────────────▼────────────────────────────────▼────────────────┐
│ 接入层 (Ingress/LB) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Load Balancer / K8s Ingress (:80/:443) │ │
│ └──────────────────────┬──────────────────────┬──────────────────────┬─────────────┘ │
└────────────────────────────────┼──────────────────────┼──────────────────────┼──────────────────────┘
│ │ │
│ REST API │ Git Ops │ Webhook
│ │ │
┌────────────────────────────────▼──────────────────────▼──────────────────────▼──────────────────────┐
│ 应 用 服 务 层 (apps/) │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │
│ │ apps/app │ │ apps/gitserver │ │ apps/git-hook │ │ apps/email │ │
│ │ 主 Web API 服务 │ │ Git HTTP/SSH 服务 │ │ Git Hook 处理器 │ │ 邮件发送 Worker │ │
│ │ :8080 │ │ :8021/:2222 │ │ Worker │ │ Worker │ │
│ │ HTTP + WebSocket │ │ HTTP + SSH │ │ 异步任务 │ │ 队列消费 │ │
│ │ 多实例部署 │ │ 单实例 │ │ 单实例 │ │ 单实例 │ │
│ └─────────┬──────────┘ └─────────┬──────────┘ └─────────┬──────────┘ └─────────┬──────────┘ │
└─────────────┼───────────────────────┼───────────────────────┼───────────────────────┼───────────────┘
│ │ │ │
│ │ │ │
┌─────────────▼───────────────────────▼───────────────────────▼───────────────────────▼───────────────┐
│ 应 用 编 排 层 (apps/operator) │
│ │
│ ┌───────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ apps/operator (Kubernetes Operator) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ App CRD │ │GitSrv CRD│ │Email CRD │ │Hook CRD │ │Mig CRD │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │
│ │ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ ▼ │ │
│ │ ┌──────────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ K8s 资源 (Deployments, Services, PVCs, Jobs) │ │ │
│ │ └──────────────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
│ │ │ │
│ │ │ │
┌─────────────▼───────────────────────▼───────────────────────▼───────────────────────▼───────────────┐
│ 业 务 逻 辑 层 (libs/service) │
│ │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ AppService { 全局服务聚合 } │ │
│ │ │ │
│ │ ┌─────────┐ ┌────────┐ ┌─────┐ ┌──────────┐ ┌───────────┐ ┌────────┐ ┌───────────┐ │ │
│ │ │ agent/ │ │ auth/ │ │git/ │ │ issue/ │ │ project/ │ │ user/ │ │ pull_req/ │ │ │
│ │ │ (8文件) │ │ (10) │ │(16) │ │ (8) │ │ (20) │ │ (12) │ │ (5) │ │ │
│ │ │ AI模型 │ │ 认证 │ │Git │ │ Issue │ │ 项目管理 │ │ 用户 │ │ PR审查 │ │ │
│ │ │ 管理 │ │ 会话 │ │操作 │ │ 追踪 │ │ 权限控制 │ │ 偏好 │ │ 合并 │ │ │
│ │ └─────────┘ └────────┘ └─────┘ └──────────┘ └───────────┘ └────────┘ └───────────┘ │ │
│ │ │ │
│ │ + utils/(project,repo,user) + ws_token + error + Pager │ │
│ └──────────────────────────────────────┬──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────┼──────────────────────────────────────────────────────────┘
┌──────────────────────┼──────────────────────┐
│ │ │
┌───────────────────▼──────────┐ ┌────────▼─────────────┐ ┌──────▼────────────────────────────┐
│ HTTP 路由层 (libs/api) │ │ WebSocket 层 │ │ 后台 Worker 层 │
│ 100 个路由文件 │ │ (libs/room) │ │ │
│ │ │ │ │ libs/queue: │
│ /api/auth/* (9端点) │ │ /ws │ │ MessageProducer │
│ /api/git/* (100+端点) │ │ /ws/rooms/{id} │ │ RedisPubSub │
│ /api/projects/* (50+端点) │ │ /ws/projects/{id} │ │ room_worker_task │
│ /api/issue/* (30+端点) │ │ │ │ start_email_worker │
│ /api/room/* (40+端点) │ │ 实时消息广播 │ │ │
│ /api/pull_request/* (20端点)│ │ 多实例同步 │ │ libs/git/hook: │
│ /api/agent/* (15端点) │ │ AI 流式输出 │ │ GitServiceHooks │
│ /api/user/* (20端点) │ │ │ │ GitHookPool │
│ /api/openapi/* (文档) │ │ │ │ │
└───────────┬────────────────┘ └──────────┬───────────┘ └─────────────┬───────────────────────┘
│ │ │
└─────────────────────────────┼───────────────────────────┘
┌─────────────────────────────────────────▼────────────────────────────────────────────────────────┐
│ 基 础 设 施 层 (Infrastructure Libs) │
│ │
│ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ │
│ │ libs/models │ │ libs/db │ │ libs/config │ │ libs/session │ │
│ │ 92 个实体文件 │ │ 数据库连接池 │ │ 全局配置管理 │ │ 会话管理中间件 │ │
│ │ Sea-ORM 实体定义 │ │ 缓存抽象 │ │ .env 加载 │ │ Redis Store │ │
│ │ 类型别名 │ │ 重试机制 │ │ 12 子模块 │ │ JWT + Cookie │ │
│ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ │
│ │ │ │ │ │
│ ┌─────────▼─────────┐ ┌────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐ │
│ │ libs/git │ │ libs/agent │ │ libs/email │ │ libs/avatar │ │
│ │ 19 子模块 │ │ 6 子模块 │ │ SMTP 邮件发送 │ │ 图片处理 │ │
│ │ libgit2 封装 │ │ OpenAI 集成 │ │ lettre 客户端 │ │ image crate │ │
│ │ HTTP + SSH 协议 │ │ Qdrant 向量库 │ │ 模板引擎 │ │ 缩放/裁剪 │ │
│ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ └─────────┬─────────┘ │
│ │ │ │ │ │
│ ┌─────────▼─────────┐ ┌────────▼─────────┐ ┌─────────▼───────────────────────▼─────────┐ │
│ │ libs/queue │ │ libs/room │ │ libs/migrate │ │
│ │ 消息队列 │ │ 实时聊天室 │ │ 82+ 数据库迁移脚本 │ │
│ │ Redis Streams │ │ 19 子模块 │ │ sea-orm-migration │ │
│ │ Pub/Sub │ │ WebSocket 管理 │ │ up/down/fresh/refresh/reset │ │
│ └─────────┬─────────┘ └─────────┬─────────┘ └─────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────▼─────────┐ ┌────────▼─────────┐ │
│ │ libs/webhook │ │ libs/rpc │ libs/transport │
│ │ (占位) │ │ (占位) │ (占位) │
│ └───────────────────┘ └─────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
│ │
│ │
┌─────────────▼──────────────────────▼────────────────────────────────────────────────────────────┐
│ 存 储 层 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ Redis │ │ Qdrant │ │ 文件系统 │ │
│ │ :5432 │ │ :6379 │ │ :6333 │ │ │ │
│ │ │ │ │ │ │ │ /data/avatars │ │
│ │ • 用户数据 │ │ • 会话存储 │ │ • 向量嵌入 │ │ /data/repos │ │
│ │ • 项目/仓库 │ │ • 缓存数据 │ │ • AI 索引 │ │ • 头像图片 │ │
│ │ • Issue/PR │ │ • Pub/Sub │ │ • 相似度检索 │ │ • Git 仓库 │ │
│ │ • Room 消息 │ │ • Stream 队列 │ │ │ │ • 上传文件 │ │
│ │ • 评论/标签 │ │ • Hook 队列 │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
│ 外部 API
┌─────────────▼────────────────────────────────────────────────────────────────────────────────────┐
│ 外 部 服 务 │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ SMTP 服务器 │ │ OpenAI API │ │ Embedding API │ │
│ │ :587 │ │ HTTPS │ │ HTTPS │ │
│ │ │ │ │ │ │ │
│ │ • 邮件发送 │ │ • 聊天补全 │ │ • 文本向量化 │ │
│ │ • 通知邮件 │ │ • AI 助手 │ │ • 相似度计算 │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
```
---
## 前端架构分层
```
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 前 端 应 用 层 (src/) │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Vite + React + TypeScript │ │
│ │ │ │
│ │ src/main.tsx ──▶ App.tsx ──▶ BrowserRouter ──▶ Routes │ │
│ │ │ │
│ │ ┌───────────────────────┐ ┌───────────────────────┐ ┌───────────────────────┐ │ │
│ │ │ 页面层 (app/) │ │ 组件层 (components/) │ │ 状态管理层 │ │ │
│ │ │ 59 页面组件 │ │ 108 UI 组件 │ │ │ │ │
│ │ │ │ │ │ │ TanStack Query │ │ │
│ │ │ auth/ (4) │ │ ui/ (66) │ │ (服务端状态) │ │ │
│ │ │ init/ (2) │ │ room/ (20) │ │ │ │ │
│ │ │ user/ (1) │ │ repository/ (8) │ │ React Context │ │ │
│ │ │ project/ (22) │ │ project/ (4) │ │ (全局状态) │ │ │
│ │ │ repository/ (12) │ │ auth/ (2) │ │ │ │ │
│ │ │ settings/ (8) │ │ layout/ (2) │ │ Local State │ │ │
│ │ │ │ │ │ │ (组件状态) │ │ │
│ │ └───────────┬───────────┘ └───────────┬───────────┘ └───────────┬────────────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────────────────┼────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────────────────────┼────────────────────────────────────────┐ │ │
│ │ │ API 客户端层 │ │ │
│ │ │ │ │ │
│ │ │ src/client/ ──▶ openapi-ts 自动生成 (从 openapi.json) │ │ │
│ │ │ 400+ API 函数 + 完整 TypeScript 类型 │ │ │
│ │ │ Axios HTTP 客户端 │ │ │
│ │ └──────────────────────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ 工具层 │ │ │
│ │ │ │ │ │
│ │ │ src/hooks/ ──▶ 自定义 React Hooks │ │ │
│ │ │ src/lib/ ──▶ 工具函数 (api-error, rsa, date 等) │ │ │
│ │ │ src/contexts/ ──▶ React Context (User, Theme 等) │ │ │
│ │ │ src/assets/ ──▶ 静态资源 (图片、图标) │ │ │
│ │ └──────────────────────────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
```
---
## 前端路由结构
```
/ 首页/仪表板
├── /auth/ 认证路由
│ ├── /login 登录页
│ ├── /register 注册页
│ ├── /password/reset 密码重置
│ └── /verify-email 邮箱验证
├── /init/ 初始化路由
│ ├── /project 初始化项目
│ └── /repository 初始化仓库
├── /user/:user 用户资料页
├── /settings/ 个人设置
│ ├── /profile 个人资料
│ ├── /account 账户设置
│ ├── /security 安全设置
│ ├── /tokens 访问令牌
│ ├── /ssh-keys SSH 密钥
│ ├── /preferences 偏好设置
│ └── /activity 活动日志
├── /project/:project_name/ 项目路由
│ ├── / 项目概览
│ ├── /activity 项目活动
│ ├── /repositories 仓库列表
│ ├── /issues Issue 列表
│ │ ├── /new 新建 Issue
│ │ └── /:issueNumber Issue 详情
│ ├── /boards 看板列表
│ │ └── /:boardId 看板详情
│ ├── /members 成员管理
│ ├── /room 聊天室列表
│ │ └── /:roomId 聊天室
│ ├── /articles 文章
│ ├── /resources 资源
│ └── /settings/ 项目设置
│ ├── /general 通用设置
│ ├── /labels 标签管理
│ ├── /billing 账单
│ ├── /members 成员管理
│ ├── /oauth OAuth 配置
│ └── /webhook Webhook 管理
├── /repository/:namespace/:repoName/ 仓库路由
│ ├── / 仓库概览
│ ├── /branches 分支管理
│ ├── /commits 提交历史
│ │ └── /:oid 提交详情
│ ├── /contributors 贡献者
│ ├── /files 文件浏览
│ ├── /tags 标签
│ ├── /pull-requests PR 列表
│ │ ├── /new 新建 PR
│ │ └── /:prNumber PR 详情
│ └── /settings 仓库设置
├── /search 全局搜索
└── /notifications 通知中心
```
---
## 后端服务依赖关系
```
┌──────────────────────────────────────────────────────────────────────────────────────┐
│ apps/ 应用依赖关系 │
│ │
│ apps/app ────────────────┐ │
│ apps/email ──────────────┤ │
│ apps/git-hook ───────────┤──▶ libs/config (全局配置) │
│ apps/gitserver ──────────┤──▶ libs/db (数据库连接池 + 缓存) │
│ apps/migrate ────────────┤──▶ libs/session (会话管理) │
│ apps/operator ───────────┘──▶ libs/migrate (数据库迁移) │
│ ├──▶ libs/service (业务逻辑层) │
│ │ │ │
│ │ ├──▶ libs/api (HTTP 路由) │
│ │ │ │
│ │ ├──▶ libs/agent (AI 服务) │
│ │ ├──▶ libs/avatar (头像处理) │
│ │ ├──▶ libs/email (邮件发送) │
│ │ ├──▶ libs/room (聊天室) │
│ │ │ │ │
│ │ │ └──▶ libs/queue (消息队列) │
│ │ │ │
│ │ └──▶ libs/git (Git 操作) │
│ │ │ │
│ │ ├──▶ git2 (libgit2 绑定) │
│ │ ├──▶ git2-hooks (Git 钩子) │
│ │ └──▶ russh (SSH 协议) │
│ │ │
│ └──▶ libs/models (数据模型 - 所有层共享) │
│ │ │
│ ├──▶ users/ (12 实体) │
│ ├──▶ projects/ (19 实体) │
│ ├──▶ repos/ (16 实体) │
│ ├──▶ issues/ (10 实体) │
│ ├──▶ pull_request/ (5 实体) │
│ ├──▶ rooms/ (11 实体) │
│ ├──▶ agents/ (6 实体) │
│ ├──▶ ai/ (3 实体) │
│ └──▶ system/ (3 实体) │
└──────────────────────────────────────────────────────────────────────────────────────┘
```
---
## libs/models 实体分组
```
┌─────────────────────────────────────────────────────────────────────────┐
│ libs/models 实体分组 (92 个) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Users (12 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ user 用户基本信息 │ │
│ │ user_2fa 双因素认证 │ │
│ │ user_activity_log 用户活动日志 │ │
│ │ user_email 用户邮箱 │ │
│ │ user_email_change 邮箱变更历史 │ │
│ │ user_notification 用户通知 │ │
│ │ user_password 用户密码 │ │
│ │ user_password_reset 密码重置令牌 │ │
│ │ user_preferences 用户偏好设置 │ │
│ │ user_relation 用户关系 │ │
│ │ user_ssh_key SSH 密钥 │ │
│ │ user_token 访问令牌 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Projects (19 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ project 项目基本信息 │ │
│ │ project_access_log 访问日志 │ │
│ │ project_activity 活动记录 │ │
│ │ project_audit_log 审计日志 │ │
│ │ project_billing 账单信息 │ │
│ │ project_billing_history 账单历史 │ │
│ │ project_board 看板 │ │
│ │ project_board_card 看板卡片 │ │
│ │ project_board_column 看板列 │ │
│ │ project_follow 项目关注 │ │
│ │ project_history_name 历史名称 │ │
│ │ project_label 项目标签 │ │
│ │ project_like 项目点赞 │ │
│ │ project_member_ 成员邀请 │ │
│ │ invitations │ │
│ │ project_member_join_ 加入问答 │ │
│ │ answers │ │
│ │ project_member_join_ 加入请求 │ │
│ │ request │ │
│ │ project_member_join_ 加入设置 │ │
│ │ settings │ │
│ │ project_members 项目成员 │ │
│ │ project_watch 项目观看 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Repos (16 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ repo 仓库基本信息 │ │
│ │ repo_branch 分支信息 │ │
│ │ repo_branch_protect 分支保护 │ │
│ │ repo_collaborator 协作者 │ │
│ │ repo_commit 提交记录 │ │
│ │ repo_fork 仓库 Fork │ │
│ │ repo_history_name 历史名称 │ │
│ │ repo_hook Git 钩子 │ │
│ │ repo_lfs_lock LFS 锁定 │ │
│ │ repo_lfs_object LFS 对象 │ │
│ │ repo_lock 仓库锁定 │ │
│ │ repo_star 仓库星标 │ │
│ │ repo_tag 仓库标签 │ │
│ │ repo_upstream 上游仓库 │ │
│ │ repo_watch 仓库观看 │ │
│ │ repo_webhook 仓库 Webhook │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Issues (10 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ issue Issue 基本信息 │ │
│ │ issue_assignee Issue 负责人 │ │
│ │ issue_comment Issue 评论 │ │
│ │ issue_comment_reaction 评论表情 │ │
│ │ issue_label Issue 标签 │ │
│ │ issue_pull_request Issue 关联 PR │ │
│ │ issue_reaction Issue 表情 │ │
│ │ issue_repo Issue 仓库 │ │
│ │ issue_subscriber Issue 订阅者 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Pull Requests (5 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ pull_request PR 基本信息 │ │
│ │ pull_request_commit PR 提交记录 │ │
│ │ pull_request_review PR 审查 │ │
│ │ pull_request_review_ PR 审查评论 │ │
│ │ comment │ │
│ │ pull_request_review_ PR 审查请求 │ │
│ │ request │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Rooms (11 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ room 聊天室基本信息 │ │
│ │ room_ai 聊天室 AI 配置 │ │
│ │ room_category 聊天室分类 │ │
│ │ room_member 聊天室成员 │ │
│ │ room_message 聊天消息 │ │
│ │ room_message_edit_ 消息编辑历史 │ │
│ │ history │ │
│ │ room_message_reaction 消息表情 │ │
│ │ room_notifications 聊天室通知 │ │
│ │ room_pin 聊天室置顶 │ │
│ │ room_thread 聊天室 Thread │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ Agents (6 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ model AI 模型 │ │
│ │ model_capability 模型能力 │ │
│ │ model_parameter_profile 模型参数配置 │ │
│ │ model_pricing 模型定价 │ │
│ │ model_provider 模型提供商 │ │
│ │ model_version 模型版本 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ AI (3 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ai_session AI 会话 │ │
│ │ ai_tool_auth AI 工具认证 │ │
│ │ ai_tool_call AI 工具调用 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ System (3 实体) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ label 系统标签 │ │
│ │ notify 系统通知 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## libs/service 业务模块
```
┌─────────────────────────────────────────────────────────────────────────┐
│ libs/service 业务模块 (93 个文件) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ agent/ AI 模型管理 (8 文件) │
│ ├── code_review AI 代码审查 │
│ ├── model AI 模型管理 │
│ ├── model_capability 模型能力管理 │
│ ├── model_parameter_ 模型参数配置 │
│ │ profile │
│ ├── model_pricing 模型定价管理 │
│ ├── model_version 模型版本管理 │
│ ├── pr_summary PR 摘要生成 │
│ └── provider 模型提供商管理 │
│ │
│ auth/ 认证管理 (10 文件) │
│ ├── captcha 验证码管理 │
│ ├── email 邮箱认证 │
│ ├── login 登录逻辑 │
│ ├── logout 登出逻辑 │
│ ├── me 当前用户信息 │
│ ├── password 密码管理 │
│ ├── register 注册逻辑 │
│ ├── rsa RSA 加密 │
│ └── totp TOTP 双因素认证 │
│ │
│ git/ Git 操作 (16 文件) │
│ ├── archive 仓库归档 │
│ ├── blocking 阻塞操作 │
│ ├── blame Git Blame │
│ ├── blob Blob 操作 │
│ ├── branch 分支操作 │
│ ├── branch_ 分支保护 │
│ │ protection │
│ ├── commit 提交操作 │
│ ├── contributors 贡献者统计 │
│ ├── diff Diff 操作 │
│ ├── init 仓库初始化 │
│ ├── refs 引用操作 │
│ ├── repo 仓库操作 │
│ ├── star 星标操作 │
│ ├── tag 标签操作 │
│ ├── tree 树操作 │
│ └── watch 观看操作 │
│ │
│ issue/ Issue 管理 (8 文件) │
│ ├── assignee 负责人管理 │
│ ├── comment 评论管理 │
│ ├── issue Issue CRUD │
│ ├── label 标签管理 │
│ ├── pull_request Issue 关联 PR │
│ ├── reaction 表情回应 │
│ ├── repo 仓库 Issue │
│ └── subscriber 订阅者管理 │
│ │
│ project/ 项目管理 (20 文件) │
│ ├── activity 项目活动 │
│ ├── audit 审计日志 │
│ ├── avatar 项目头像 │
│ ├── billing 账单管理 │
│ ├── board 看板管理 │
│ ├── can_use 权限检查 │
│ ├── info 项目信息 │
│ ├── init 项目初始化 │
│ ├── invitation 邀请管理 │
│ ├── join_answers 加入问答 │
│ ├── join_request 加入请求 │
│ ├── join_settings 加入设置 │
│ ├── labels 标签管理 │
│ ├── like 点赞管理 │
│ ├── members 成员管理 │
│ ├── repo 仓库管理 │
│ ├── repo_ 仓库权限 │
│ │ permission │
│ ├── settings 项目设置 │
│ ├── standard 项目标准 │
│ ├── transfer_repo 仓库转移 │
│ └── watch 观看管理 │
│ │
│ pull_request/ PR 管理 (5 文件) │
│ ├── merge PR 合并 │
│ ├── pull_request PR CRUD │
│ ├── review PR 审查 │
│ ├── review_comment 审查评论 │
│ └── review_request 审查请求 │
│ │
│ user/ 用户管理 (12 文件) │
│ ├── access_key 访问密钥 │
│ ├── avatar 用户头像 │
│ ├── chpc 用户 CHPC │
│ ├── notification 通知管理 │
│ ├── notify 通知发送 │
│ ├── preferences 偏好设置 │
│ ├── profile 用户资料 │
│ ├── projects 用户项目 │
│ ├── repository 用户仓库 │
│ ├── ssh_key SSH 密钥 │
│ ├── subscribe 订阅管理 │
│ └── user_info 用户信息 │
│ │
│ utils/ 工具函数 (3 文件) │
│ ├── project 项目工具 │
│ ├── repo 仓库工具 │
│ └── user 用户工具 │
│ │
│ ws_token WebSocket Token 服务 │
│ error 服务层错误 │
│ Pager 分页结构体 │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## libs/api 路由模块
```
┌─────────────────────────────────────────────────────────────────────────┐
│ libs/api 路由模块 (100 个文件) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ auth/ 认证路由 (9 文件) │
│ ├── captcha 验证码接口 │
│ ├── email 邮箱认证接口 │
│ ├── login 登录接口 │
│ ├── logout 登出接口 │
│ ├── me 当前用户接口 │
│ ├── password 密码接口 │
│ ├── register 注册接口 │
│ ├── totp TOTP 接口 │
│ └── ws_token WebSocket Token 接口 │
│ │
│ git/ Git 路由 (18 文件) │
│ ├── archive 归档接口 │
│ ├── blame Blame 接口 │
│ ├── blob Blob 接口 │
│ ├── branch 分支接口 │
│ ├── branch_ 分支保护接口 │
│ │ protection │
│ ├── commit 提交接口 │
│ ├── contributors 贡献者接口 │
│ ├── diff Diff 接口 │
│ ├── init 初始化接口 │
│ ├── refs 引用接口 │
│ ├── repo 仓库接口 │
│ ├── star 星标接口 │
│ ├── tag 标签接口 │
│ ├── tree 树接口 │
│ └── watch 观看接口 │
│ │
│ project/ 项目路由 (17 文件) │
│ ├── activity 活动接口 │
│ ├── audit 审计接口 │
│ ├── billing 账单接口 │
│ ├── board 看板接口 │
│ ├── info 信息接口 │
│ ├── init 初始化接口 │
│ ├── invitation 邀请接口 │
│ ├── join_answers 加入问答接口 │
│ ├── join_request 加入请求接口 │
│ ├── join_settings 加入设置接口 │
│ ├── labels 标签接口 │
│ ├── like 点赞接口 │
│ ├── members 成员接口 │
│ ├── repo 仓库接口 │
│ ├── settings 设置接口 │
│ ├── transfer_repo 仓库转移接口 │
│ └── watch 观看接口 │
│ │
│ issue/ Issue 路由 (10 文件) │
│ ├── assignee 负责人接口 │
│ ├── comment 评论接口 │
│ ├── comment_ 评论表情接口 │
│ │ reaction │
│ ├── issue_label Issue 标签接口 │
│ ├── label 标签接口 │
│ ├── pull_request Issue 关联 PR 接口 │
│ ├── reaction 表情接口 │
│ ├── repo 仓库 Issue 接口 │
│ └── subscriber 订阅者接口 │
│ │
│ room/ 聊天室路由 (14 文件) │
│ ├── ai AI 接口 │
│ ├── category 分类接口 │
│ ├── draft_and_ 草稿和历史接口 │
│ │ history │
│ ├── member 成员接口 │
│ ├── message 消息接口 │
│ ├── notification 通知接口 │
│ ├── pin 置顶接口 │
│ ├── reaction 表情接口 │
│ ├── room 聊天室接口 │
│ ├── thread Thread 接口 │
│ ├── ws WebSocket 接口 │
│ ├── ws_handler WebSocket 处理器 │
│ ├── ws_types WebSocket 类型 │
│ └── ws_universal 通用 WebSocket 接口 │
│ │
│ pull_request/ PR 路由 (5 文件) │
│ ├── merge 合并接口 │
│ ├── pull_request PR CRUD 接口 │
│ ├── review 审查接口 │
│ ├── review_comment 审查评论接口 │
│ └── review_request 审查请求接口 │
│ │
│ agent/ AI Agent 路由 (8 文件) │
│ ├── code_review 代码审查接口 │
│ ├── model 模型接口 │
│ ├── model_ 模型能力接口 │
│ │ capability │
│ ├── model_ 模型参数配置接口 │
│ │ parameter_profile │
│ ├── model_pricing 模型定价接口 │
│ ├── model_version 模型版本接口 │
│ ├── pr_summary PR 摘要接口 │
│ └── provider 模型提供商接口 │
│ │
│ user/ 用户路由 (10 文件) │
│ ├── access_key 访问密钥接口 │
│ ├── chpc CHPC 接口 │
│ ├── notification 通知接口 │
│ ├── preferences 偏好接口 │
│ ├── profile 资料接口 │
│ ├── projects 项目接口 │
│ ├── repository 仓库接口 │
│ ├── ssh_key SSH 密钥接口 │
│ ├── subscribe 订阅接口 │
│ └── user_info 用户信息接口 │
│ │
│ openapi/ OpenAPI 文档生成 │
│ route/ 路由聚合 │
│ error/ API 错误处理 │
└─────────────────────────────────────────────────────────────────────────┘
```
---
## 服务间通信机制
```
┌────────────────────────────────────────────────────────────────────────────────────────────┐
│ 服务间通信机制 │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Redis (核心通信总线) │ │
│ │ │ │
│ │ Redis Streams ──▶ 异步消息队列 │ │
│ │ ├── room:stream:{room_id} 房间消息持久化 │ │
│ │ └── email:stream 邮件发送队列 │ │
│ │ │ │
│ │ Redis Pub/Sub ──▶ 实时事件广播 │ │
│ │ ├── room:pub:{room_id} 房间级广播 │ │
│ │ └── project:pub:{proj_id} 项目级广播 │ │
│ │ │ │
│ │ Redis Lists ──▶ 任务队列 │ │
│ │ ├── {hook}:sync Git Hook 同步任务 │ │
│ │ ├── {hook}:fsck Git Hook 完整性检查 │ │
│ │ └── {hook}:gc Git Hook 垃圾回收 │ │
│ └──────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ HTTP/REST API ──▶ 同步服务调用 │ │
│ │ ├── app ↔ gitserver Git 元数据查询 │ │
│ │ └── app → 外部 AI 服务 OpenAI 兼容 API 调用 │ │
│ └──────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ WebSocket ──▶ 客户端实时通信 │ │
│ │ ├── /ws 通用 WebSocket (多房间订阅) │ │
│ │ ├── /ws/rooms/{room_id} 房间级 WebSocket │ │
│ │ └── /ws/projects/{proj_id} 项目级 WebSocket │ │
│ └──────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Kubernetes CRD + Operator ──▶ 基础设施编排 │ │
│ │ ├── apps.code.dev App CRD → Deployment + Service │ │
│ │ ├── gitservers.code.dev GitServer CRD → Deployment + Service + PVC │ │
│ │ ├── emailworkers.code.dev EmailWorker CRD → Deployment │ │
│ │ ├── githooks.code.dev GitHook CRD → Deployment + ConfigMap │ │
│ │ └── migrates.code.dev Migrate CRD → Job │ │
│ └──────────────────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────────────────────┘
```
---
## 数据流详解
### 1. 聊天消息流程
```
客户端 A app 实例 1 Redis app 实例 2 客户端 B
│ │ │ │ │
│── WS 发送消息 ───────▶│ │ │ │
│ │── XADD ──────────────▶│ │ │
│ │ room:stream:{id} │ │ │
│ │── PUBLISH ────────────▶│ │ │
│ │ room:pub:{id} │ │ │
│ │ │── 事件通知 ────────────▶│ │
│ │ │ │── WS 推送 ────────────▶│
│◀─ ACK ───────────────│ │ │ │
│ │ │ │ │
│ │◀──── XREADGROUP ─────│ │ │
│ │ (room_worker) │ │ │
│ │── 写入 PostgreSQL ────│ │ │
```
### 2. Git Push 流程
```
客户端 gitserver Redis git-hook PostgreSQL
│ │ │ │ │
│── git push ────────▶│ │ │ │
│ (HTTP/SSH) │ │ │ │
│ │── git-receive-pack──▶│ │ │
│ │── LPUSH ────────────▶│ │ │
│ │ {hook}:sync │ │ │
│◀─ ACK ─────────────│ │ │ │
│ │ │── BRPOPLPUSH ─────▶│ │
│ │ │ │── 同步元数据 ────────▶│
│ │ │ │── 可选: fsck/gc ─────▶│
│ │ │◀── XACK ──────────│ │
```
### 3. 邮件发送流程
```
业务逻辑 app Redis email-worker SMTP
│ │ │ │ │
│── 触发邮件 ────────▶│ │ │ │
│ │── XADD ───────────▶│ │ │
│ │ email:stream │ │ │
│◀─ 返回 ───────────│ │ │ │
│ │ │── XREADGROUP ─────▶│ │
│ │ │ │── 渲染模板 ──────────▶│
│ │ │ │── SMTP 发送 ─────────▶│
│ │ │◀── XACK ──────────│ │
```
### 4. AI 聊天流程
```
客户端 app OpenAI API Qdrant PostgreSQL
│ │ │ │ │
│── AI 消息 ──────────▶│ │ │ │
│ │── 生成 Embedding ──▶│ │ │
│ │◀──── 向量 ──────────│ │ │
│ │── 存储向量 ─────────────────────────────▶│ │
│ │── 流式 Chat ─────────▶│ │ │
│◀─ Stream Chunk ──────│◀──── Stream ─────────│ │ │
│ │ │ │ │
│ │── 保存消息 ────────────────────────────────────────────────▶│
│ │── 检索相似消息 ────────────────────────▶│ │
│ │◀── 相似结果 ───────────────────────────│ │
```
---
## 技术栈汇总
### 后端技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| **语言** | Rust | Edition 2024 |
| **Web 框架** | Actix-web | 4.13.0 |
| **WebSocket** | Actix-ws | 0.4.0 |
| **ORM** | SeaORM | 2.0.0-rc.37 |
| **数据库** | PostgreSQL | - |
| **缓存/消息** | Redis | 1.1.0 |
| **向量库** | Qdrant | 1.17.0 |
| **Git** | git2 / russh | 0.20.0 / 0.55.0 |
| **邮件** | Lettre | 0.11.19 |
| **AI** | async-openai | 0.34.0 |
| **K8s** | kube-rs | 0.98 |
| **gRPC** | Tonic | 0.14.5 |
| **日志** | slog / tracing | 2.8 / 0.1.44 |
### 前端技术栈
| 类别 | 技术 | 版本 |
|------|------|------|
| **语言** | TypeScript | 5.9 |
| **框架** | React | 19.2 |
| **路由** | React Router | 7.13 |
| **构建** | Vite + SWC | 8.0 |
| **UI** | shadcn/ui + Tailwind | 4.11 / 4.2 |
| **状态** | TanStack Query | 5.96 |
| **HTTP** | Axios + OpenAPI 生成 | 1.7 |
| **Markdown** | react-markdown + Shiki | 10 / 1 |
| **拖拽** | dnd-kit | 6.3 |
---
## Docker 与 K8s 部署
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ Docker 镜像 (6 个) │
│ │
│ docker/app.Dockerfile ──▶ apps/app 主应用镜像 │
│ docker/email-worker.Dockerfile ──▶ apps/email 邮件 Worker 镜像 │
│ docker/git-hook.Dockerfile ──▶ apps/git-hook Git Hook 镜像 │
│ docker/gitserver.Dockerfile ──▶ apps/gitserver Git Server 镜像 │
│ docker/migrate.Dockerfile ──▶ apps/migrate 数据库迁移镜像 │
│ docker/operator.Dockerfile ──▶ apps/operator K8s Operator 镜像 │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ Kubernetes CRD (5 个) │
│ │
│ docker/crd/app-crd.yaml ──▶ apps.code.dev │
│ docker/crd/gitserver-crd.yaml ──▶ gitservers.code.dev │
│ docker/crd/email-worker-crd.yaml ──▶ emailworkers.code.dev │
│ docker/crd/git-hook-crd.yaml ──▶ githooks.code.dev │
│ docker/crd/migrate-crd.yaml ──▶ migrates.code.dev │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ K8s 部署配置 │
│ │
│ docker/operator/deployment.yaml ──▶ Operator Deployment │
│ docker/operator/example/ ──▶ CRD 使用示例 │
│ code-system.yaml │
└──────────────────────────────────────────────────────────────────────────────┘
```
---
## 关键设计特点
| 特点 | 描述 |
|------|------|
| **Monorepo 架构** | Rust workspace + 前端 monorepo统一管理 |
| **清晰分层** | 路由层 → 业务层 → 基础设施层 → 存储层,职责明确 |
| **异步优先** | 基于 Redis Streams 的异步消息处理 |
| **实时通信** | WebSocket + Redis Pub/Sub 实现多实例同步 |
| **K8s 原生** | Operator + 5 个 CRD 管理全生命周期 |
| **类型安全** | OpenAPI 自动生成 TypeScript 客户端 |
| **可扩展** | 服务独立部署,水平扩展 |
| **Git 兼容** | 完整支持 HTTP/SSH Git 协议 + LFS |
| **AI 集成** | 原生集成 OpenAI 兼容 API + 向量检索 |
| **92 个数据库实体** | 覆盖用户、项目、仓库、Issue、PR、聊天室、AI 等完整业务域 |

27
eslint.config.js Normal file
View File

@ -0,0 +1,27 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import {defineConfig, globalIgnores} from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'src/client/**']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
rules: {
// Disable set-state-in-effect as it's a valid pattern for initializing form state from server data
'react-hooks/set-state-in-effect': 'off',
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<link href="/logo.png" rel="icon" type="image/svg+xml"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>GitDataAi</title>
</head>
<body>
<div id="root"></div>
<script src="/src/main.tsx" type="module"></script>
</body>
</html>

View File

@ -0,0 +1,26 @@
[package]
name = "agent-tool-derive"
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]
proc-macro = true
path = "src/lib.rs"
[dependencies]
syn = { version = "2", features = ["full", "extra-traits"] }
quote = "1"
proc-macro2 = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
convert_case = "0.11"
futures = "0.3"

View File

@ -0,0 +1,373 @@
//! Procedural macro for generating tool definitions from functions.
//!
//! # Example
//!
//! ```
//! use agent_tool_derive::tool;
//!
//! #[tool(description = "Search issues by title")]
//! fn search_issues(
//! title: String,
//! status: Option<String>,
//! ) -> Result<Vec<serde_json::Value>, String> {
//! Ok(vec![])
//! }
//! ```
//!
//! Generates:
//! - A `SearchIssuesParameters` struct (serde Deserialize)
//! - A `SEARCH_ISSUES_DEFINITION: ToolDefinition` constant
//! - A `register_search_issues(registry: &mut ToolRegistry)` helper
extern crate proc_macro;
use convert_case::{Case, Casing};
use proc_macro::TokenStream;
use quote::{format_ident, quote};
use std::collections::HashMap;
use syn::punctuated::Punctuated;
use syn::{
Expr, ExprLit, Ident, Lit, Meta, ReturnType, Token, Type,
parse::{Parse, ParseStream},
};
/// Parse the attribute arguments: `description = "...", params(...), required(...)`
struct ToolArgs {
description: Option<String>,
param_descriptions: HashMap<String, String>,
required: Vec<String>,
}
impl Parse for ToolArgs {
fn parse(input: ParseStream) -> syn::Result<Self> {
Self::parse_from(input)
}
}
impl ToolArgs {
fn new() -> Self {
Self {
description: None,
param_descriptions: HashMap::new(),
required: Vec::new(),
}
}
fn parse_from(input: ParseStream) -> syn::Result<Self> {
let mut this = Self::new();
if input.is_empty() {
return Ok(this);
}
let meta_list: Punctuated<Meta, Token![,]> = Punctuated::parse_terminated(input)?;
for meta in meta_list {
match meta {
Meta::NameValue(nv) => {
let ident = nv
.path
.get_ident()
.ok_or_else(|| syn::Error::new_spanned(&nv.path, "expected identifier"))?;
if ident == "description" {
if let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = nv.value
{
this.description = Some(s.value());
} else {
return Err(syn::Error::new_spanned(
&nv.value,
"description must be a string literal",
));
}
}
}
Meta::List(list) if list.path.is_ident("params") => {
let inner: Punctuated<Meta, Token![,]> =
list.parse_args_with(Punctuated::parse_terminated)?;
for item in inner {
if let Meta::NameValue(nv) = item {
let param_name = nv
.path
.get_ident()
.ok_or_else(|| {
syn::Error::new_spanned(&nv.path, "expected identifier")
})?
.to_string();
if let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = nv.value
{
this.param_descriptions.insert(param_name, s.value());
}
}
}
}
Meta::List(list) if list.path.is_ident("required") => {
let required_vars: Punctuated<Ident, Token![,]> =
list.parse_args_with(Punctuated::parse_terminated)?;
for var in required_vars {
this.required.push(var.to_string());
}
}
_ => {}
}
}
Ok(this)
}
}
/// Map a Rust type to its JSON Schema type name.
fn json_type(ty: &Type) -> proc_macro2::TokenStream {
use syn::Type as T;
let segs = match ty {
T::Path(p) => &p.path.segments,
_ => return quote! { "type": "object" },
};
let last = segs.last().map(|s| &s.ident);
let args = segs.last().and_then(|s| {
if let syn::PathArguments::AngleBracketed(a) = &s.arguments {
Some(&a.args)
} else {
None
}
});
match (last.map(|i| i.to_string()).as_deref(), args) {
(Some("Vec" | "vec::Vec"), Some(args)) if !args.is_empty() => {
if let syn::GenericArgument::Type(inner) = &args[0] {
let inner_type = json_type(inner);
return quote! {
{
"type": "array",
"items": { #inner_type }
}
};
}
quote! { "type": "array" }
}
(Some("String" | "str" | "char"), _) => quote! { "type": "string" },
(Some("bool"), _) => quote! { "type": "boolean" },
(Some("i8" | "i16" | "i32" | "i64" | "isize"), _) => quote! { "type": "integer" },
(Some("u8" | "u16" | "u32" | "u64" | "usize"), _) => quote! { "type": "integer" },
(Some("f32" | "f64"), _) => quote! { "type": "number" },
_ => quote! { "type": "object" },
}
}
/// Extract return type info from `-> Result<T, E>`.
fn parse_return_type(
ret: &ReturnType,
) -> syn::Result<(proc_macro2::TokenStream, proc_macro2::TokenStream)> {
match ret {
ReturnType::Type(_, ty) => {
let ty = &**ty;
if let Type::Path(p) = ty {
let last = p
.path
.segments
.last()
.ok_or_else(|| syn::Error::new_spanned(&p.path, "invalid return type"))?;
if last.ident == "Result" {
if let syn::PathArguments::AngleBracketed(a) = &last.arguments {
let args = &a.args;
if args.len() == 2 {
let ok = &args[0];
let err = &args[1];
return Ok((quote!(#ok), quote!(#err)));
}
}
return Err(syn::Error::new_spanned(
&last,
"Result must have 2 type parameters",
));
}
}
Err(syn::Error::new_spanned(
ty,
"function must return Result<T, E>",
))
}
_ => Err(syn::Error::new_spanned(
ret,
"function must have a return type",
)),
}
}
/// The `#[tool]` attribute macro.
///
/// Usage:
/// ```
/// #[tool(description = "Tool description", params(
/// arg1 = "Description of arg1",
/// arg2 = "Description of arg2",
/// ))]
/// async fn my_tool(arg1: String, arg2: Option<i32>) -> Result<serde_json::Value, String> {
/// Ok(serde_json::json!({}))
/// }
/// ```
///
/// Generates:
/// - `MyToolParameters` struct with serde Deserialize
/// - `MY_TOOL_DEFINITION: ToolDefinition` constant
/// - `register_my_tool(registry: &mut ToolRegistry)` helper function
#[proc_macro_attribute]
pub fn tool(args: TokenStream, input: TokenStream) -> TokenStream {
let args = syn::parse_macro_input!(args as ToolArgs);
let input_fn = syn::parse_macro_input!(input as syn::ItemFn);
let fn_name = &input_fn.sig.ident;
let fn_name_str = fn_name.to_string();
let vis = &input_fn.vis;
let is_async = input_fn.sig.asyncness.is_some();
// Parse return type: Result<T, E>
let (_output_type, _error_type) = match parse_return_type(&input_fn.sig.output) {
Ok(t) => t,
Err(e) => return e.into_compile_error().into(),
};
// PascalCase struct name
let struct_name = format_ident!("{}", fn_name_str.to_case(Case::Pascal));
let params_struct_name = format_ident!("{}Parameters", struct_name);
let definition_const_name = format_ident!("{}_DEFINITION", fn_name_str.to_uppercase());
let register_fn_name = format_ident!("register_{}", fn_name_str);
// Extract parameters from function signature
let mut param_names: Vec<Ident> = Vec::new();
let mut param_types: Vec<Type> = Vec::new();
let mut param_json_types: Vec<proc_macro2::TokenStream> = Vec::new();
let mut param_descs: Vec<proc_macro2::TokenStream> = Vec::new();
let required_args = args.required.clone();
for arg in &input_fn.sig.inputs {
let syn::FnArg::Typed(pat_type) = arg else {
continue;
};
let syn::Pat::Ident(pat_ident) = &*pat_type.pat else {
continue;
};
let name = &pat_ident.ident;
let ty = &*pat_type.ty;
let name_str = name.to_string();
let desc = args
.param_descriptions
.get(&name_str)
.map(|s| quote! { #s.to_string() })
.unwrap_or_else(|| quote! { format!("Parameter {}", #name_str) });
param_names.push(format_ident!("{}", name.to_string()));
param_types.push(ty.clone());
param_json_types.push(json_type(ty));
param_descs.push(desc);
}
// Which params are required (not Option<T>)
let required: Vec<proc_macro2::TokenStream> = if required_args.is_empty() {
param_names
.iter()
.filter(|name| {
let name_str = name.to_string();
!args
.param_descriptions
.contains_key(&format!("{}_opt", name_str))
})
.map(|name| quote! { stringify!(#name) })
.collect()
} else {
required_args.iter().map(|s| quote! { #s }).collect()
};
// Tool description
let tool_description = args
.description
.map(|s| quote! { #s.to_string() })
.unwrap_or_else(|| quote! { format!("Function {}", #fn_name_str) });
// Call invocation (async vs sync)
let call_args = param_names.iter().map(|n| quote! { args.#n });
let fn_call = if is_async {
quote! { #fn_name(#(#call_args),*).await }
} else {
quote! { #fn_name(#(#call_args),*) }
};
let expanded = quote! {
// Parameters struct: deserialized from JSON args by serde
#[derive(serde::Deserialize)]
#vis struct #params_struct_name {
#(#vis #param_names: #param_types,)*
}
// Keep the original function unchanged
#input_fn
// Static ToolDefinition constant — register this with ToolRegistry
#vis const #definition_const_name: agent::ToolDefinition = agent::ToolDefinition {
name: #fn_name_str.to_string(),
description: Some(#tool_description),
parameters: Some(agent::ToolSchema {
schema_type: "object".to_string(),
properties: Some({
let mut map = std::collections::HashMap::new();
#({
map.insert(stringify!(#param_names).to_string(), agent::ToolParam {
name: stringify!(#param_names).to_string(),
param_type: {
let jt = #param_json_types;
jt.get("type")
.and_then(|v| v.as_str())
.unwrap_or("object")
.to_string()
},
description: Some(#param_descs),
required: true,
properties: None,
items: None,
});
})*
map
}),
required: Some(vec![#(#required.to_string()),*]),
}),
strict: false,
};
/// Registers this tool in the given registry.
///
/// Generated by `#[tool]` macro for function `#fn_name_str`.
#vis fn #register_fn_name(registry: &mut agent::ToolRegistry) {
let def = #definition_const_name.clone();
let fn_name = #fn_name_str.to_string();
registry.register_fn(fn_name, move |_ctx, args| {
let args: #params_struct_name = match serde_json::from_value(args) {
Ok(a) => a,
Err(e) => {
return std::pin::Pin::new(Box::new(async move {
Err(agent::ToolError::ParseError(e.to_string()))
}))
}
};
std::pin::Pin::new(Box::new(async move {
let result = #fn_call;
match result {
Ok(v) => Ok(serde_json::to_value(v).unwrap_or(serde_json::Value::Null)),
Err(e) => Err(agent::ToolError::ExecutionError(e.to_string())),
}
}))
});
}
};
// We need to use boxed futures for the return type.
// Since we can't add runtime dependencies to the proc-macro crate,
// we emit the .boxed() call and the caller must ensure
// `use futures::FutureExt;` or equivalent is in scope.
// The generated code requires: `futures::FutureExt` (for .boxed()).
// Re-emit with futures dependency note
TokenStream::from(expanded)
}

37
libs/agent/Cargo.toml Normal file
View File

@ -0,0 +1,37 @@
[package]
name = "agent"
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 = "agent"
[dependencies]
async-openai = { version = "0.34.0", features = ["embedding", "chat-completion", "model"] }
tokio = { workspace = true }
async-trait = { workspace = true }
qdrant-client = { workspace = true }
sea-orm = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
db = { workspace = true }
config = { path = "../config" }
models = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
futures = { workspace = true }
tiktoken-rs = { workspace = true }
agent-tool-derive = { path = "../agent-tool-derive" }
once_cell = { workspace = true }
regex = { workspace = true }
[lints]
workspace = true

200
libs/agent/chat/context.rs Normal file
View File

@ -0,0 +1,200 @@
use async_openai::types::chat::{
ChatCompletionRequestAssistantMessage, ChatCompletionRequestAssistantMessageContent,
ChatCompletionRequestDeveloperMessage, ChatCompletionRequestDeveloperMessageContent,
ChatCompletionRequestFunctionMessage, ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessage, ChatCompletionRequestSystemMessageContent,
ChatCompletionRequestToolMessage, ChatCompletionRequestToolMessageContent,
ChatCompletionRequestUserMessage, ChatCompletionRequestUserMessageContent,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use uuid::Uuid;
use crate::compact::MessageSummary;
use models::rooms::room_message::Model as RoomMessageModel;
/// Sender type for AI context, supporting all roles in the chat.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum AiContextSenderType {
/// Regular user message
User,
/// AI assistant message
Ai,
/// System message (e.g., summary, notification)
System,
/// Developer message (for system-level instructions)
Developer,
/// Tool call message
Function,
/// Tool result message
FunctionResult,
}
impl AiContextSenderType {
pub fn from_sender_type(sender_type: &models::rooms::MessageSenderType) -> Self {
match sender_type {
models::rooms::MessageSenderType::Member => Self::User,
models::rooms::MessageSenderType::Admin => Self::User,
models::rooms::MessageSenderType::Owner => Self::User,
models::rooms::MessageSenderType::Ai => Self::Ai,
models::rooms::MessageSenderType::System => Self::System,
models::rooms::MessageSenderType::Tool => Self::Function,
models::rooms::MessageSenderType::Guest => Self::User,
}
}
}
/// Room message context for AI processing.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RoomMessageContext {
pub uid: Uuid,
pub sender_type: AiContextSenderType,
pub sender_uid: Option<Uuid>,
pub sender_name: Option<String>,
pub content: String,
pub content_type: models::rooms::MessageContentType,
pub send_at: DateTime<Utc>,
/// Tool call ID for FunctionResult messages, used to associate tool results with their calls.
pub tool_call_id: Option<String>,
}
impl RoomMessageContext {
pub fn from_model(model: &RoomMessageModel, sender_name: Option<String>) -> Self {
Self {
uid: model.id,
sender_type: AiContextSenderType::from_sender_type(&model.sender_type),
sender_uid: model.sender_id,
sender_name,
content: model.content.clone(),
content_type: model.content_type.clone(),
send_at: model.send_at,
tool_call_id: Self::extract_tool_call_id(&model.content),
}
}
fn extract_tool_call_id(content: &str) -> Option<String> {
let content = content.trim();
if let Ok(v) = serde_json::from_str::<Value>(content) {
v.get("tool_call_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
pub fn from_model_with_names(
model: &RoomMessageModel,
user_names: &HashMap<Uuid, String>,
) -> Self {
let sender_name = model
.sender_id
.and_then(|uid| user_names.get(&uid).cloned());
Self::from_model(model, sender_name)
}
pub fn to_message(&self) -> ChatCompletionRequestMessage {
match self.sender_type {
AiContextSenderType::User => {
ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: ChatCompletionRequestUserMessageContent::Text(self.display_content()),
name: self.sender_name.clone(),
})
}
AiContextSenderType::Ai => {
ChatCompletionRequestMessage::Assistant(ChatCompletionRequestAssistantMessage {
content: Some(ChatCompletionRequestAssistantMessageContent::Text(
self.display_content(),
)),
name: self.sender_name.clone(),
refusal: None,
audio: None,
tool_calls: None,
#[allow(deprecated)]
function_call: None,
})
}
AiContextSenderType::System => {
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(
self.display_content(),
),
name: self.sender_name.clone(),
})
}
AiContextSenderType::Developer => {
ChatCompletionRequestMessage::Developer(ChatCompletionRequestDeveloperMessage {
content: ChatCompletionRequestDeveloperMessageContent::Text(
self.display_content(),
),
name: self.sender_name.clone(),
})
}
AiContextSenderType::Function => {
ChatCompletionRequestMessage::Function(ChatCompletionRequestFunctionMessage {
content: Some(self.content.clone()),
name: self.display_content(), // Function name is stored in content
})
}
AiContextSenderType::FunctionResult => {
ChatCompletionRequestMessage::Tool(ChatCompletionRequestToolMessage {
content: ChatCompletionRequestToolMessageContent::Text(self.display_content()),
tool_call_id: self
.tool_call_id
.clone()
.unwrap_or_else(|| "unknown".to_string()),
})
}
}
}
fn display_content(&self) -> String {
let mut content = self.content.trim().to_string();
if content.is_empty() {
content = match self.content_type {
models::rooms::MessageContentType::Text => "[empty]".to_string(),
models::rooms::MessageContentType::Image => "[image]".to_string(),
models::rooms::MessageContentType::Audio => "[audio]".to_string(),
models::rooms::MessageContentType::Video => "[video]".to_string(),
models::rooms::MessageContentType::File => "[file]".to_string(),
};
}
if let Some(sender_name) = &self.sender_name {
content = format!("[{}] {}", sender_name, content);
}
content
}
}
impl From<&RoomMessageModel> for RoomMessageContext {
fn from(model: &RoomMessageModel) -> Self {
RoomMessageContext::from_model(model, None)
}
}
impl From<MessageSummary> for RoomMessageContext {
fn from(summary: MessageSummary) -> Self {
// Map MessageSenderType to AiContextSenderType
let sender_type = AiContextSenderType::from_sender_type(&summary.sender_type);
// For FunctionResult (tool results), ensure tool_call_id is set
let tool_call_id = if sender_type == AiContextSenderType::FunctionResult {
summary.tool_call_id
} else {
None
};
Self {
uid: summary.id,
sender_type,
sender_uid: summary.sender_id,
sender_name: Some(summary.sender_name),
content: summary.content,
content_type: summary.content_type,
send_at: summary.send_at,
tool_call_id,
}
}
}

61
libs/agent/chat/mod.rs Normal file
View File

@ -0,0 +1,61 @@
use std::pin::Pin;
use async_openai::types::chat::ChatCompletionTool;
use db::cache::AppCache;
use db::database::AppDatabase;
use models::agents::model;
use models::projects::project;
use models::repos::repo;
use models::rooms::{room, room_message};
use models::users::user;
use std::collections::HashMap;
use uuid::Uuid;
/// Maximum recursion rounds for tool-call loops (AI → tool → result → AI).
pub const DEFAULT_MAX_TOOL_DEPTH: usize = 3;
/// A single chunk from an AI streaming response.
#[derive(Debug, Clone)]
pub struct AiStreamChunk {
pub content: String,
pub done: bool,
}
/// Optional streaming callback: called for each token chunk.
pub type StreamCallback = Box<
dyn Fn(AiStreamChunk) -> Pin<Box<dyn std::future::Future<Output = ()> + Send>> + Send + Sync,
>;
pub struct AiRequest {
pub db: AppDatabase,
pub cache: AppCache,
pub model: model::Model,
pub project: project::Model,
pub sender: user::Model,
pub room: room::Model,
pub input: String,
pub mention: Vec<Mention>,
pub history: Vec<room_message::Model>,
/// Optional user name mapping: user_id -> username
pub user_names: HashMap<Uuid, String>,
pub temperature: f64,
pub max_tokens: i32,
pub top_p: f64,
pub frequency_penalty: f64,
pub presence_penalty: f64,
pub think: bool,
/// OpenAI tool definitions. If None or empty, tool calling is disabled.
pub tools: Option<Vec<ChatCompletionTool>>,
/// Maximum tool-call recursion depth (AI → tool → result → AI loops). Default: 3.
pub max_tool_depth: usize,
}
pub enum Mention {
User(user::Model),
Repo(repo::Model),
}
pub mod context;
pub mod service;
pub use context::{AiContextSenderType, RoomMessageContext};
pub use service::ChatService;

655
libs/agent/chat/service.rs Normal file
View File

@ -0,0 +1,655 @@
use async_openai::Client;
use async_openai::config::OpenAIConfig;
use async_openai::types::chat::{
ChatCompletionMessageToolCalls, ChatCompletionRequestAssistantMessage,
ChatCompletionRequestAssistantMessageContent, ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessage, ChatCompletionRequestUserMessage, ChatCompletionTool,
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
CreateChatCompletionStreamResponse, FinishReason, ReasoningEffort, ToolChoiceOptions,
};
use futures::StreamExt;
use models::projects::project_skill;
use models::rooms::room_ai;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid;
use super::context::RoomMessageContext;
use super::{AiRequest, AiStreamChunk, Mention, StreamCallback};
use crate::compact::{CompactConfig, CompactService};
use crate::embed::EmbedService;
use crate::error::{AgentError, Result};
use crate::perception::{PerceptionService, SkillEntry, ToolCallEvent};
use crate::tool::{ToolCall, ToolContext, ToolExecutor};
/// Service for handling AI chat requests in rooms.
pub struct ChatService {
openai_client: Client<OpenAIConfig>,
compact_service: Option<CompactService>,
embed_service: Option<EmbedService>,
perception_service: PerceptionService,
}
impl ChatService {
pub fn new(openai_client: Client<OpenAIConfig>) -> Self {
Self {
openai_client,
compact_service: None,
embed_service: None,
perception_service: PerceptionService::default(),
}
}
pub fn with_compact_service(mut self, compact_service: CompactService) -> Self {
self.compact_service = Some(compact_service);
self
}
pub fn with_embed_service(mut self, embed_service: EmbedService) -> Self {
self.embed_service = Some(embed_service);
self
}
pub fn with_perception_service(mut self, perception_service: PerceptionService) -> Self {
self.perception_service = perception_service;
self
}
#[allow(deprecated)]
pub async fn process(&self, request: AiRequest) -> Result<String> {
let tools: Vec<ChatCompletionTool> = request.tools.clone().unwrap_or_default();
let tools_enabled = !tools.is_empty();
let tool_choice = tools_enabled.then(|| {
async_openai::types::chat::ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::Auto)
});
let think = request.think;
let max_tool_depth = request.max_tool_depth;
let top_p = request.top_p;
let frequency_penalty = request.frequency_penalty;
let presence_penalty = request.presence_penalty;
let temperature_f = request.temperature;
let max_tokens_i = request.max_tokens;
let mut messages = self.build_messages(&request).await?;
let room_ai = room_ai::Entity::find()
.filter(room_ai::Column::Room.eq(request.room.id))
.filter(room_ai::Column::Model.eq(request.model.id))
.one(&request.db)
.await?;
let model_name = request.model.name.clone();
let temperature = room_ai
.as_ref()
.and_then(|r| r.temperature.map(|v| v as f32))
.unwrap_or(temperature_f as f32);
let max_tokens = room_ai
.as_ref()
.and_then(|r| r.max_tokens.map(|v| v as u32))
.unwrap_or(max_tokens_i as u32);
let mut tool_depth = 0;
loop {
let req = CreateChatCompletionRequest {
model: model_name.clone(),
messages: messages.clone(),
temperature: Some(temperature),
max_completion_tokens: Some(max_tokens),
top_p: Some(top_p as f32),
frequency_penalty: Some(frequency_penalty as f32),
presence_penalty: Some(presence_penalty as f32),
stream: Some(false),
reasoning_effort: Some(if think {
ReasoningEffort::High
} else {
ReasoningEffort::None
}),
tools: if tools_enabled {
Some(
tools
.iter()
.map(|t| ChatCompletionTools::Function(t.clone()))
.collect(),
)
} else {
None
},
tool_choice: tool_choice.clone(),
..Default::default()
};
let response: CreateChatCompletionResponse = self
.openai_client
.chat()
.create(req)
.await
.map_err(|e| AgentError::OpenAi(e.to_string()))?;
let choice = response
.choices
.into_iter()
.next()
.ok_or_else(|| AgentError::Internal("no choice in response".into()))?;
if tools_enabled {
if let Some(ref tool_calls) = choice.message.tool_calls {
if !tool_calls.is_empty() {
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: choice
.message
.content
.clone()
.map(ChatCompletionRequestAssistantMessageContent::Text),
name: None,
refusal: None,
audio: None,
tool_calls: Some(tool_calls.clone()),
function_call: None,
},
));
let calls: Vec<ToolCall> = tool_calls
.iter()
.filter_map(|tc| {
if let ChatCompletionMessageToolCalls::Function(
async_openai::types::chat::ChatCompletionMessageToolCall {
id,
function,
},
) = tc
{
Some(ToolCall {
id: id.clone(),
name: function.name.clone(),
arguments: function.arguments.clone(),
})
} else {
None
}
})
.collect();
if !calls.is_empty() {
let tool_messages = self.execute_tool_calls(calls, &request).await?;
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(String::new());
}
continue;
}
}
}
}
let text = choice.message.content.unwrap_or_default();
return Ok(text);
}
}
#[allow(deprecated)]
pub async fn process_stream(&self, request: AiRequest, on_chunk: StreamCallback) -> Result<()> {
let tools: Vec<ChatCompletionTool> = request.tools.clone().unwrap_or_default();
let tools_enabled = !tools.is_empty();
let tool_choice = tools_enabled.then(|| {
async_openai::types::chat::ChatCompletionToolChoiceOption::Mode(ToolChoiceOptions::Auto)
});
let think = request.think;
let max_tool_depth = request.max_tool_depth;
let top_p = request.top_p;
let frequency_penalty = request.frequency_penalty;
let presence_penalty = request.presence_penalty;
let temperature_f = request.temperature;
let max_tokens_i = request.max_tokens;
let mut messages = self.build_messages(&request).await?;
let room_ai = room_ai::Entity::find()
.filter(room_ai::Column::Room.eq(request.room.id))
.filter(room_ai::Column::Model.eq(request.model.id))
.one(&request.db)
.await?;
let model_name = request.model.name.clone();
let temperature = room_ai
.as_ref()
.and_then(|r| r.temperature.map(|v| v as f32))
.unwrap_or(temperature_f as f32);
let max_tokens = room_ai
.as_ref()
.and_then(|r| r.max_tokens.map(|v| v as u32))
.unwrap_or(max_tokens_i as u32);
let mut tool_depth = 0;
loop {
let req = CreateChatCompletionRequest {
model: model_name.clone(),
messages: messages.clone(),
temperature: Some(temperature),
max_completion_tokens: Some(max_tokens),
top_p: Some(top_p as f32),
frequency_penalty: Some(frequency_penalty as f32),
presence_penalty: Some(presence_penalty as f32),
stream: Some(true),
reasoning_effort: Some(if think {
ReasoningEffort::High
} else {
ReasoningEffort::None
}),
tools: if tools_enabled {
Some(
tools
.iter()
.map(|t| ChatCompletionTools::Function(t.clone()))
.collect(),
)
} else {
None
},
tool_choice: tool_choice.clone(),
..Default::default()
};
let mut stream = self
.openai_client
.chat()
.create_stream(req)
.await
.map_err(|e| AgentError::OpenAi(e.to_string()))?;
let mut text_accumulated = String::new();
let mut tool_call_chunks: Vec<ToolCallChunkAccum> = Vec::new();
let mut finish_reason: Option<FinishReason> = None;
while let Some(chunk_result) = stream.next().await {
let chunk: CreateChatCompletionStreamResponse =
chunk_result.map_err(|e| AgentError::OpenAi(e.to_string()))?;
let choice = match chunk.choices.first() {
Some(c) => c,
None => continue,
};
// Track finish reason
if let Some(ref fr) = choice.finish_reason {
finish_reason = Some(fr.clone());
}
// Text delta
if let Some(content) = &choice.delta.content {
text_accumulated.push_str(content);
on_chunk(AiStreamChunk {
content: text_accumulated.clone(),
done: false,
})
.await;
}
// Tool call deltas
if let Some(ref tool_chunks) = choice.delta.tool_calls {
for tc in tool_chunks {
let idx = tc.index as usize;
if tool_call_chunks.len() <= idx {
tool_call_chunks.resize(idx + 1, ToolCallChunkAccum::default());
}
if let Some(ref id) = tc.id {
tool_call_chunks[idx].id = Some(id.clone());
}
if let Some(ref fc) = tc.function {
if let Some(ref name) = fc.name {
tool_call_chunks[idx].name.push_str(name);
}
if let Some(ref args) = fc.arguments {
tool_call_chunks[idx].arguments.push_str(args);
}
}
}
}
}
let has_tool_calls = matches!(
finish_reason,
Some(FinishReason::ToolCalls) | Some(FinishReason::FunctionCall)
);
if has_tool_calls && tools_enabled {
// Send final text chunk
on_chunk(AiStreamChunk {
content: text_accumulated.clone(),
done: true,
})
.await;
// Build ToolCall list from accumulated chunks
let tool_calls: Vec<_> = tool_call_chunks
.into_iter()
.filter(|c| !c.name.is_empty())
.map(|c| ToolCall {
id: c.id.unwrap_or_else(|| Uuid::new_v4().to_string()),
name: c.name,
arguments: c.arguments,
})
.collect();
if !tool_calls.is_empty() {
// Append assistant message with tool calls to history
messages.push(ChatCompletionRequestMessage::Assistant(
ChatCompletionRequestAssistantMessage {
content: Some(
ChatCompletionRequestAssistantMessageContent::Text(
text_accumulated,
),
),
name: None,
refusal: None,
audio: None,
tool_calls: Some(
tool_calls
.iter()
.map(|tc| {
ChatCompletionMessageToolCalls::Function(
async_openai::types::chat::ChatCompletionMessageToolCall {
id: tc.id.clone(),
function: async_openai::types::chat::FunctionCall {
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
},
)
})
.collect(),
),
function_call: None,
},
));
let tool_messages = self.execute_tool_calls(tool_calls, &request).await?;
messages.extend(tool_messages);
tool_depth += 1;
if tool_depth >= max_tool_depth {
return Ok(());
}
continue;
}
}
on_chunk(AiStreamChunk {
content: text_accumulated,
done: true,
})
.await;
return Ok(());
}
}
/// Executes a batch of tool calls and returns the tool result messages.
async fn execute_tool_calls(
&self,
calls: Vec<ToolCall>,
request: &AiRequest,
) -> Result<Vec<ChatCompletionRequestMessage>> {
let mut ctx = ToolContext::new(
request.db.clone(),
request.cache.clone(),
request.room.id,
Some(request.sender.uid),
)
.with_project(request.project.id);
let executor = ToolExecutor::new();
let results = executor
.execute_batch(calls, &mut ctx)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?;
Ok(ToolExecutor::to_tool_messages(&results))
}
async fn build_messages(
&self,
request: &AiRequest,
) -> Result<Vec<ChatCompletionRequestMessage>> {
let mut messages = Vec::new();
let mut processed_history = Vec::new();
if let Some(compact_service) = &self.compact_service {
// Auto-compact: only compresses when token count exceeds threshold
let config = CompactConfig::default();
match compact_service
.compact_room_auto(request.room.id, Some(request.user_names.clone()), config)
.await
{
Ok(compact_summary) => {
if !compact_summary.summary.is_empty() {
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
format!("Conversation summary:\n{}", compact_summary.summary),
),
..Default::default()
},
));
}
processed_history = compact_summary.retained;
}
Err(e) => {
let _ = e;
}
}
}
if !processed_history.is_empty() {
for msg_summary in processed_history {
let ctx = RoomMessageContext::from(msg_summary);
messages.push(ctx.to_message());
}
} else {
for msg in &request.history {
let ctx = RoomMessageContext::from_model_with_names(msg, &request.user_names);
messages.push(ctx.to_message());
}
}
if let Some(embed_service) = &self.embed_service {
for mention in &request.mention {
match mention {
Mention::Repo(repo) => {
let query = format!(
"{} {}",
repo.repo_name,
repo.description.as_deref().unwrap_or_default()
);
match embed_service.search_issues(&query, 5).await {
Ok(issues) if !issues.is_empty() => {
let context = format!(
"Related issues:\n{}",
issues
.iter()
.map(|i| format!("- {}", i.payload.text))
.collect::<Vec<_>>()
.join("\n")
);
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
context,
),
..Default::default()
},
));
}
Err(e) => {
let _ = e;
}
_ => {}
}
match embed_service.search_repos(&query, 3).await {
Ok(repos) if !repos.is_empty() => {
let context = format!(
"Related repositories:\n{}",
repos
.iter()
.map(|r| format!("- {}", r.payload.text))
.collect::<Vec<_>>()
.join("\n")
);
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
context,
),
..Default::default()
},
));
}
Err(e) => {
let _ = e;
}
_ => {}
}
}
Mention::User(user) => {
let mut profile_parts = vec![format!("Username: {}", user.username)];
if let Some(ref display_name) = user.display_name {
profile_parts.push(format!("Display name: {}", display_name));
}
if let Some(ref org) = user.organization {
profile_parts.push(format!("Organization: {}", org));
}
if let Some(ref website) = user.website_url {
profile_parts.push(format!("Website: {}", website));
}
messages.push(ChatCompletionRequestMessage::System(
ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
format!("Mentioned user profile:\n{}", profile_parts.join("\n")),
),
..Default::default()
},
));
}
}
}
}
// Inject relevant skills via the perception system (auto + active + passive).
let skill_contexts = self.build_skill_context(request).await;
for ctx in skill_contexts {
messages.push(ctx.to_system_message() as ChatCompletionRequestMessage);
}
// Inject relevant past conversation memories via vector similarity.
let memories = self.build_memory_context(request).await;
for mem in memories {
messages.push(mem.to_system_message());
}
messages.push(ChatCompletionRequestMessage::User(
ChatCompletionRequestUserMessage {
content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
request.input.clone(),
),
..Default::default()
},
));
Ok(messages)
}
/// Fetch enabled skills for the current project and run them through the
/// perception system (auto + active + passive) to inject relevant context.
async fn build_skill_context(
&self,
request: &AiRequest,
) -> Vec<crate::perception::SkillContext> {
// Fetch enabled skills for this project.
let skills: Vec<SkillEntry> = match project_skill::Entity::find()
.filter(project_skill::Column::ProjectUuid.eq(request.project.id))
.filter(project_skill::Column::Enabled.eq(true))
.all(&request.db)
.await
{
Ok(models) => models
.into_iter()
.map(|s| SkillEntry {
slug: s.slug,
name: s.name,
description: s.description,
content: s.content,
})
.collect(),
Err(_) => return Vec::new(),
};
if skills.is_empty() {
return Vec::new();
}
// Build history text for auto-awareness scoring.
let history_texts: Vec<String> = request
.history
.iter()
.rev()
.take(10)
.map(|msg| msg.content.clone())
.collect();
// Active + passive + auto perception (keyword-based).
let tool_events: Vec<ToolCallEvent> = Vec::new(); // Tool calls tracked in loop via process()
let keyword_skills = self
.perception_service
.inject_skills(&request.input, &history_texts, &tool_events, &skills)
.await;
// Vector-aware active perception: semantic search for skills via Qdrant.
let mut vector_skills = Vec::new();
if let Some(embed_service) = &self.embed_service {
let awareness = crate::perception::VectorActiveAwareness::default();
vector_skills = awareness
.detect(embed_service, &request.input, &request.project.id.to_string())
.await;
}
// Merge: deduplicate by label, preferring vector results (higher signal).
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
for ctx in vector_skills {
if seen.insert(ctx.label.clone()) {
result.push(ctx);
}
}
for ctx in keyword_skills {
if seen.insert(ctx.label.clone()) {
result.push(ctx);
}
}
result
}
/// Inject relevant past conversation memories via vector similarity search.
async fn build_memory_context(
&self,
request: &AiRequest,
) -> Vec<crate::perception::vector::MemoryContext> {
let embed_service = match &self.embed_service {
Some(s) => s,
None => return Vec::new(),
};
// Search memories by current input semantic similarity.
let awareness = crate::perception::VectorPassiveAwareness::default();
awareness
.detect(embed_service, &request.input, &request.room.id.to_string())
.await
}
}
#[derive(Clone, Debug, Default)]
struct ToolCallChunkAccum {
id: Option<String>,
name: String,
arguments: String,
}

279
libs/agent/client.rs Normal file
View File

@ -0,0 +1,279 @@
//! Unified AI client with built-in retry, token tracking, and session recording.
//!
//! Provides a single entry point for all AI calls with:
//! - Exponential backoff with jitter (max 3 retries)
//! - Retryable error classification (429/500/502/503/504)
//! - Token usage tracking (input/output)
use async_openai::Client;
use async_openai::config::OpenAIConfig;
use async_openai::types::chat::{
ChatCompletionRequestMessage, ChatCompletionTool, ChatCompletionToolChoiceOption,
ChatCompletionTools, CreateChatCompletionRequest, CreateChatCompletionResponse,
};
use std::time::Instant;
use crate::error::{AgentError, Result};
/// Configuration for the AI client.
#[derive(Clone)]
pub struct AiClientConfig {
pub api_key: String,
pub base_url: Option<String>,
}
impl AiClientConfig {
pub fn new(api_key: String) -> Self {
Self {
api_key,
base_url: None,
}
}
pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn build_client(&self) -> Client<OpenAIConfig> {
let mut config = OpenAIConfig::new().with_api_key(&self.api_key);
if let Some(ref url) = self.base_url {
config = config.with_api_base(url);
}
Client::with_config(config)
}
}
/// Response from an AI call, including usage statistics.
#[derive(Debug, Clone)]
pub struct AiCallResponse {
pub content: String,
pub input_tokens: i64,
pub output_tokens: i64,
pub latency_ms: i64,
}
impl AiCallResponse {
pub fn total_tokens(&self) -> i64 {
self.input_tokens + self.output_tokens
}
}
/// Internal state for retry tracking.
#[derive(Debug)]
struct RetryState {
attempt: u32,
max_retries: u32,
max_backoff_ms: u64,
}
impl RetryState {
fn new(max_retries: u32) -> Self {
Self {
attempt: 0,
max_retries,
max_backoff_ms: 5000,
}
}
fn should_retry(&self) -> bool {
self.attempt < self.max_retries
}
/// Calculate backoff duration with "full jitter" technique.
fn backoff_duration(&self) -> std::time::Duration {
let exp = self.attempt.min(5);
// base = 500 * 2^exp, capped at max_backoff_ms
let base_ms = 500u64
.saturating_mul(2u64.pow(exp))
.min(self.max_backoff_ms);
// jitter: random [0, base_ms/2]
let jitter = (fastrand_u64(base_ms / 2 + 1)) as u64;
std::time::Duration::from_millis(base_ms / 2 + jitter)
}
fn next(&mut self) {
self.attempt += 1;
}
}
/// Fast pseudo-random u64 using a simple LCG.
/// Good enough for jitter — not for cryptography.
fn fastrand_u64(n: u64) -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static STATE: AtomicU64 = AtomicU64::new(0x193_667_6a_5e_7c_57);
if n <= 1 {
return 0;
}
let mut current = STATE.load(Ordering::Relaxed);
loop {
let new_val = current.wrapping_mul(6364136223846793005).wrapping_add(1);
match STATE.compare_exchange_weak(current, new_val, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return new_val % n,
Err(actual) => current = actual,
}
}
}
/// Determine if an error is retryable.
fn is_retryable_error(err: &async_openai::error::OpenAIError) -> bool {
use async_openai::error::OpenAIError;
match err {
// Network errors (DNS failure, connection refused, timeout) are always retryable
OpenAIError::Reqwest(_) => true,
// For API errors, check the error code string (e.g., "rate_limit_exceeded")
OpenAIError::ApiError(api_err) => api_err.code.as_ref().map_or(false, |code| {
matches!(
code.as_str(),
"rate_limit_exceeded"
| "internal_server_error"
| "service_unavailable"
| "gateway_timeout"
| "bad_gateway"
)
}),
_ => false,
}
}
/// Call the AI model with automatic retry.
pub async fn call_with_retry(
messages: &[ChatCompletionRequestMessage],
model: &str,
config: &AiClientConfig,
max_retries: Option<u32>,
) -> Result<AiCallResponse> {
let client = config.build_client();
let mut state = RetryState::new(max_retries.unwrap_or(3));
loop {
let start = Instant::now();
let req = CreateChatCompletionRequest {
model: model.to_string(),
messages: messages.to_vec(),
..Default::default()
};
let result = client.chat().create(req).await;
match result {
Ok(response) => {
let latency_ms = start.elapsed().as_millis() as i64;
let (input_tokens, output_tokens) = extract_usage(&response);
return Ok(AiCallResponse {
content: extract_content(&response),
input_tokens,
output_tokens,
latency_ms,
});
}
Err(err) => {
if state.should_retry() && is_retryable_error(&err) {
let duration = state.backoff_duration();
eprintln!(
"AI call failed (attempt {}/{}), retrying in {:?}",
state.attempt + 1,
state.max_retries,
duration
);
tokio::time::sleep(duration).await;
state.next();
continue;
}
return Err(AgentError::OpenAi(err.to_string()));
}
}
}
}
/// Call with custom parameters (temperature, max_tokens, optional tools).
pub async fn call_with_params(
messages: &[ChatCompletionRequestMessage],
model: &str,
config: &AiClientConfig,
temperature: f32,
max_tokens: u32,
max_retries: Option<u32>,
tools: Option<&[ChatCompletionTool]>,
) -> Result<AiCallResponse> {
let client = config.build_client();
let mut state = RetryState::new(max_retries.unwrap_or(3));
loop {
let start = Instant::now();
let req = CreateChatCompletionRequest {
model: model.to_string(),
messages: messages.to_vec(),
temperature: Some(temperature),
max_completion_tokens: Some(max_tokens),
tools: tools.map(|ts| {
ts.iter()
.map(|t| ChatCompletionTools::Function(t.clone()))
.collect()
}),
tool_choice: tools.filter(|ts| !ts.is_empty()).map(|_| {
ChatCompletionToolChoiceOption::Mode(
async_openai::types::chat::ToolChoiceOptions::Auto,
)
}),
..Default::default()
};
let result = client.chat().create(req).await;
match result {
Ok(response) => {
let latency_ms = start.elapsed().as_millis() as i64;
let (input_tokens, output_tokens) = extract_usage(&response);
return Ok(AiCallResponse {
content: extract_content(&response),
input_tokens,
output_tokens,
latency_ms,
});
}
Err(err) => {
if state.should_retry() && is_retryable_error(&err) {
let duration = state.backoff_duration();
eprintln!(
"AI call failed (attempt {}/{}), retrying in {:?}",
state.attempt + 1,
state.max_retries,
duration
);
tokio::time::sleep(duration).await;
state.next();
continue;
}
return Err(AgentError::OpenAi(err.to_string()));
}
}
}
}
/// Extract text content from a chat completion response.
fn extract_content(response: &CreateChatCompletionResponse) -> String {
response
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default()
}
/// Extract usage (input/output tokens) from a response.
fn extract_usage(response: &CreateChatCompletionResponse) -> (i64, i64) {
response
.usage
.as_ref()
.map(|u| {
(
i64::try_from(u.prompt_tokens).unwrap_or(0),
i64::try_from(u.completion_tokens).unwrap_or(0),
)
})
.unwrap_or((0, 0))
}

View File

@ -0,0 +1,45 @@
use super::types::{CompactSummary, MessageSummary};
pub fn messages_to_text<F>(
messages: &[models::rooms::room_message::Model],
sender_mapper: F,
) -> String
where
F: Fn(&models::rooms::room_message::Model) -> String,
{
messages
.iter()
.map(|m| {
let sender = sender_mapper(m);
format!("[{}] {}: {}", m.send_at, sender, m.content)
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn retained_as_text(retained: &[MessageSummary]) -> String {
retained
.iter()
.map(|m| format!("[{}] {}: {}", m.send_at, m.sender_name, m.content))
.collect::<Vec<_>>()
.join("\n")
}
pub fn summary_content(summary: &CompactSummary) -> String {
if summary.summary.is_empty() {
format!(
"## Recent conversation ({} messages)\n\n{}",
summary.retained.len(),
retained_as_text(&summary.retained)
)
} else {
format!(
"## Earlier conversation ({} messages summarised)\n{}\n\n\
## Most recent {} messages\n\n{}",
summary.messages_compressed,
summary.summary,
summary.retained.len(),
retained_as_text(&summary.retained)
)
}
}

View File

@ -0,0 +1,8 @@
//! Context compaction for AI sessions and room message history.
pub mod helpers;
pub mod service;
pub mod types;
pub use service::CompactService;
pub use types::{CompactConfig, CompactLevel, CompactSummary, MessageSummary, ThresholdResult};

View File

@ -0,0 +1,467 @@
use async_openai::Client;
use async_openai::config::OpenAIConfig;
use async_openai::types::chat::{
ChatCompletionRequestMessage, ChatCompletionRequestUserMessage, CreateChatCompletionRequest,
CreateChatCompletionResponse,
};
use chrono::Utc;
use models::ColumnTrait;
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 uuid::Uuid;
use crate::AgentError;
use crate::compact::helpers::summary_content;
use crate::compact::types::{
CompactConfig, CompactLevel, CompactSummary, MessageSummary, ThresholdResult,
};
use crate::tokent::{TokenUsage, resolve_usage};
#[derive(Clone)]
pub struct CompactService {
db: DatabaseConnection,
openai: Client<OpenAIConfig>,
model: String,
}
impl CompactService {
pub fn new(db: DatabaseConnection, openai: Client<OpenAIConfig>, model: String) -> Self {
Self { db, openai, model }
}
pub async fn compact_room(
&self,
room_id: Uuid,
level: CompactLevel,
user_names: Option<std::collections::HashMap<Uuid, String>>,
) -> Result<CompactSummary, AgentError> {
let messages = self.fetch_room_messages(room_id).await?;
let user_ids: Vec<Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.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<MessageSummary> = 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<MessageSummary> = retained_messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
let (summary, remote_usage) = self.summarize_messages(to_summarize).await?;
// Build text of what was summarized (for tiktoken fallback)
let summarized_text = to_summarize
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.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),
})
}
pub async fn compact_session(
&self,
session_id: Uuid,
level: CompactLevel,
user_names: Option<std::collections::HashMap<Uuid, String>>,
) -> Result<CompactSummary, AgentError> {
let messages: Vec<RoomMessageModel> = RoomMessage::find()
.filter(RmCol::Room.eq(session_id))
.order_by_asc(RmCol::Seq)
.all(&self.db)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?;
if messages.is_empty() {
return Err(AgentError::Internal("session has no messages".into()));
}
let user_ids: Vec<Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.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<MessageSummary> = messages
.iter()
.map(|m| Self::message_to_summary(m, &user_name_map))
.collect();
return Ok(CompactSummary {
session_id,
room_id: Uuid::nil(),
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<MessageSummary> = retained_messages
.iter()
.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?;
// Build text of what was summarized (for tiktoken fallback)
let summarized_text = to_summarize
.iter()
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let usage = resolve_usage(remote_usage, &self.model, &summarized_text, &summary);
Ok(CompactSummary {
session_id,
room_id: Uuid::nil(),
retained,
summary,
compacted_at: Utc::now(),
messages_compressed: to_summarize.len(),
usage: Some(usage),
})
}
pub fn summary_as_system_message(summary: &CompactSummary) -> ChatCompletionRequestMessage {
let content = summary_content(summary);
ChatCompletionRequestMessage::System(
async_openai::types::chat::ChatCompletionRequestSystemMessage {
content: async_openai::types::chat::ChatCompletionRequestSystemMessageContent::Text(
content,
),
..Default::default()
},
)
}
/// 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(
&self,
room_id: Uuid,
config: CompactConfig,
) -> Result<ThresholdResult, AgentError> {
let messages = self.fetch_room_messages(room_id).await?;
let tokens = self.estimate_message_tokens(&messages);
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)
} else {
config.default_level
};
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<std::collections::HashMap<Uuid, String>>,
config: CompactConfig,
) -> Result<CompactSummary, AgentError> {
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<Uuid> = 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<MessageSummary> = 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<std::collections::HashMap<Uuid, String>>,
) -> Result<CompactSummary, AgentError> {
let messages = self.fetch_room_messages(room_id).await?;
let user_ids: Vec<Uuid> = 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<MessageSummary> = 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<MessageSummary> = 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::<Vec<_>>()
.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<Uuid, String>,
) -> 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(),
sender_id: m.sender_id,
sender_name,
content: m.content.clone(),
content_type: m.content_type.clone(),
tool_call_id: Self::extract_tool_call_id(&m.content),
send_at: m.send_at,
}
}
fn extract_tool_call_id(content: &str) -> Option<String> {
let content = content.trim();
if let Ok(v) = serde_json::from_str::<Value>(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<Vec<RoomMessageModel>, AgentError> {
let messages: Vec<RoomMessageModel> = 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],
) -> Result<std::collections::HashMap<Uuid, String>, AgentError> {
use std::collections::HashMap;
let mut map = HashMap::new();
if !user_ids.is_empty() {
let users = User::find()
.filter(UserCol::Uid.is_in(user_ids.to_vec()))
.all(&self.db)
.await
.map_err(|e| AgentError::Internal(e.to_string()))?;
for user in users {
map.insert(user.uid, user.username);
}
}
Ok(map)
}
async fn summarize_messages(
&self,
messages: &[RoomMessageModel],
) -> Result<(String, Option<TokenUsage>), AgentError> {
// Collect distinct user IDs
let user_ids: Vec<Uuid> = messages
.iter()
.filter_map(|m| m.sender_id)
.collect::<std::collections::HashSet<_>>()
.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) {
return username.clone();
}
}
m.sender_type.to_string()
};
let body = crate::compact::helpers::messages_to_text(messages, sender_mapper);
let user_msg = ChatCompletionRequestMessage::User(ChatCompletionRequestUserMessage {
content: async_openai::types::chat::ChatCompletionRequestUserMessageContent::Text(
format!(
"Summarise the following conversation concisely, preserving all key facts, \
decisions, and any pending or in-progress work. \
Use this format:\n\n\
**Summary:** <one-paragraph overview>\n\
**Key decisions:** <bullet list or 'none'>\n\
**Open items:** <bullet list or 'none'>\n\n\
Conversation:\n\n{}",
body
),
),
..Default::default()
});
let request = CreateChatCompletionRequest {
model: self.model.clone(),
messages: vec![user_msg],
stream: Some(false),
..Default::default()
};
let response: CreateChatCompletionResponse = self
.openai
.chat()
.create(request)
.await
.map_err(|e| AgentError::OpenAi(e.to_string()))?;
let text = response
.choices
.first()
.and_then(|c| c.message.content.clone())
.unwrap_or_default();
// Prefer remote usage; fall back to None (caller will use tiktoken via resolve_usage)
let remote_usage = response
.usage
.as_ref()
.and_then(|u| TokenUsage::from_remote(u.prompt_tokens, u.completion_tokens));
Ok((text, remote_usage))
}
}

130
libs/agent/compact/types.rs Normal file
View File

@ -0,0 +1,130 @@
use chrono::{DateTime, Utc};
use models::rooms::{
MessageContentType, MessageSenderType, room_message::Model as RoomMessageModel,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::tokent::TokenUsage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSummary {
pub id: Uuid,
pub sender_type: MessageSenderType,
pub sender_id: Option<Uuid>,
pub sender_name: String,
pub content: String,
pub content_type: MessageContentType,
/// Tool call ID extracted from message content JSON, if present.
pub tool_call_id: Option<String>,
pub send_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactSummary {
pub session_id: Uuid,
pub room_id: Uuid,
pub retained: Vec<MessageSummary>,
pub summary: String,
pub compacted_at: DateTime<Utc>,
pub messages_compressed: usize,
/// Token usage for the compaction AI call. `None` if usage data was unavailable.
pub usage: Option<TokenUsage>,
}
#[derive(Debug, Clone, Copy)]
pub enum CompactLevel {
Light,
Aggressive,
}
impl CompactLevel {
pub fn retain_count(&self) -> usize {
match self {
CompactLevel::Light => 5,
CompactLevel::Aggressive => 2,
}
}
/// Auto-select level based on estimated token count and config.
///
/// - `Light` (retain 5): when tokens are moderately over threshold
/// - `Aggressive` (retain 2): when tokens are severely over threshold (2x+)
pub fn auto_select(estimated_tokens: usize, threshold: usize) -> Self {
if threshold == 0 {
return CompactLevel::Light;
}
if estimated_tokens >= threshold * 2 {
CompactLevel::Aggressive
} else {
CompactLevel::Light
}
}
}
/// Configuration for automatic compaction.
#[derive(Debug, Clone, Copy)]
pub struct CompactConfig {
/// Only trigger compaction when estimated token count exceeds this.
/// Set to 0 to disable threshold (always compact when messages > retain_count).
pub token_threshold: usize,
/// If true, auto-select level based on how far over the threshold we are.
/// If false, always use `default_level`.
pub auto_level: bool,
/// Fallback level when `auto_level` is false.
pub default_level: CompactLevel,
}
impl Default for CompactConfig {
fn default() -> Self {
// Trigger when estimated tokens exceed ~8k (reasonable for a context window)
Self {
token_threshold: 8000,
auto_level: true,
default_level: CompactLevel::Light,
}
}
}
/// Result of a threshold check before deciding whether to compact.
#[derive(Debug)]
pub enum ThresholdResult {
/// Token count is below threshold — skip compaction.
Skip { estimated_tokens: usize },
/// Token count exceeds threshold — compact with this level.
Compact {
estimated_tokens: usize,
level: CompactLevel,
},
}
impl From<RoomMessageModel> for MessageSummary {
fn from(m: RoomMessageModel) -> Self {
let sender_type = m.sender_type.clone();
let content = m.content.clone();
Self {
id: m.id,
sender_type: sender_type.clone(),
sender_id: m.sender_id,
sender_name: sender_type.to_string(),
content,
content_type: m.content_type.clone(),
tool_call_id: Self::extract_tool_call_id(&m.content),
send_at: m.send_at,
}
}
}
impl MessageSummary {
fn extract_tool_call_id(content: &str) -> Option<String> {
let content = content.trim();
if let Ok(v) = serde_json::from_str::<Value>(content) {
v.get("tool_call_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
} else {
None
}
}
}

209
libs/agent/embed/client.rs Normal file
View File

@ -0,0 +1,209 @@
use async_openai::Client;
use async_openai::types::embeddings::CreateEmbeddingRequestArgs;
use serde::{Deserialize, Serialize};
use crate::embed::qdrant::QdrantClient;
pub struct EmbedClient {
openai: Client<async_openai::config::OpenAIConfig>,
qdrant: QdrantClient,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedVector {
pub id: String,
pub vector: Vec<f32>,
pub payload: EmbedPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbedPayload {
pub entity_type: String,
pub entity_id: String,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub score: f32,
pub payload: EmbedPayload,
}
impl EmbedClient {
pub fn new(openai: Client<async_openai::config::OpenAIConfig>, qdrant: QdrantClient) -> Self {
Self { openai, qdrant }
}
pub async fn embed_text(&self, text: &str, model: &str) -> crate::Result<Vec<f32>> {
let request = CreateEmbeddingRequestArgs::default()
.model(model)
.input(text)
.build()
.map_err(|e| crate::AgentError::OpenAi(e.to_string()))?;
let response = self
.openai
.embeddings()
.create(request)
.await
.map_err(|e| crate::AgentError::OpenAi(e.to_string()))?;
response
.data
.first()
.map(|d| d.embedding.clone())
.ok_or_else(|| crate::AgentError::OpenAi("no embedding returned".into()))
}
pub async fn embed_batch(&self, texts: &[String], model: &str) -> crate::Result<Vec<Vec<f32>>> {
let request = CreateEmbeddingRequestArgs::default()
.model(model)
.input(texts.to_vec())
.build()
.map_err(|e| crate::AgentError::OpenAi(e.to_string()))?;
let response = self
.openai
.embeddings()
.create(request)
.await
.map_err(|e| crate::AgentError::OpenAi(e.to_string()))?;
let mut embeddings = vec![Vec::new(); texts.len()];
for data in response.data {
if (data.index as usize) < embeddings.len() {
embeddings[data.index as usize] = data.embedding;
}
}
Ok(embeddings)
}
pub async fn upsert(&self, points: Vec<EmbedVector>) -> crate::Result<()> {
self.qdrant.upsert_points(points).await
}
pub async fn search(
&self,
query: &str,
entity_type: &str,
model: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
let vector = self.embed_text(query, model).await?;
self.qdrant.search(&vector, entity_type, limit).await
}
pub async fn search_with_filter(
&self,
query: &str,
entity_type: &str,
model: &str,
limit: usize,
filter: qdrant_client::qdrant::Filter,
) -> crate::Result<Vec<SearchResult>> {
let vector = self.embed_text(query, model).await?;
self.qdrant
.search_with_filter(&vector, entity_type, limit, filter)
.await
}
pub async fn delete_by_entity_id(
&self,
entity_type: &str,
entity_id: &str,
) -> crate::Result<()> {
self.qdrant.delete_by_filter(entity_type, entity_id).await
}
pub async fn ensure_collection(&self, entity_type: &str, dimensions: u64) -> crate::Result<()> {
self.qdrant.ensure_collection(entity_type, dimensions).await
}
pub async fn ensure_memory_collection(&self, dimensions: u64) -> crate::Result<()> {
self.qdrant.ensure_memory_collection(dimensions).await
}
pub async fn ensure_skill_collection(&self, dimensions: u64) -> crate::Result<()> {
self.qdrant.ensure_skill_collection(dimensions).await
}
/// Embed and store a conversation memory (message) in Qdrant.
pub async fn embed_memory(
&self,
id: &str,
text: &str,
room_id: &str,
user_id: Option<&str>,
) -> crate::Result<()> {
let vector = self.embed_text(text, "").await?;
let point = EmbedVector {
id: id.to_string(),
vector,
payload: EmbedPayload {
entity_type: "memory".to_string(),
entity_id: room_id.to_string(),
text: text.to_string(),
extra: serde_json::json!({ "user_id": user_id }).into(),
},
};
self.qdrant.upsert_points(vec![point]).await
}
/// Search memory embeddings by semantic similarity within a room.
pub async fn search_memories(
&self,
query: &str,
model: &str,
room_id: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
let vector = self.embed_text(query, model).await?;
let mut results = self.qdrant.search_memory(&vector, limit + 1).await?;
// Filter to the specific room
results.retain(|r| r.payload.entity_id == room_id);
results.truncate(limit);
Ok(results)
}
/// Embed and store a skill in Qdrant.
pub async fn embed_skill(
&self,
id: &str,
name: &str,
description: &str,
content: &str,
project_uuid: &str,
) -> crate::Result<()> {
let text = format!("{}: {} {}", name, description, content);
let vector = self.embed_text(&text, "").await?;
let point = EmbedVector {
id: id.to_string(),
vector,
payload: EmbedPayload {
entity_type: "skill".to_string(),
entity_id: project_uuid.to_string(),
text,
extra: serde_json::json!({ "name": name, "description": description }).into(),
},
};
self.qdrant.upsert_points(vec![point]).await
}
/// Search skill embeddings by semantic similarity within a project.
pub async fn search_skills(
&self,
query: &str,
model: &str,
project_uuid: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
let vector = self.embed_text(query, model).await?;
let mut results = self.qdrant.search_skill(&vector, limit + 1).await?;
results.retain(|r| r.payload.entity_id == project_uuid);
results.truncate(limit);
Ok(results)
}
}

30
libs/agent/embed/mod.rs Normal file
View File

@ -0,0 +1,30 @@
pub mod client;
pub mod qdrant;
pub mod service;
use async_openai::config::OpenAIConfig;
pub use client::{EmbedClient, EmbedPayload, EmbedVector, SearchResult};
pub use qdrant::QdrantClient;
pub use service::{EmbedService, Embeddable};
pub async fn new_embed_client(config: &config::AppConfig) -> crate::Result<EmbedClient> {
let base_url = config
.get_embed_model_base_url()
.map_err(|e| crate::AgentError::Internal(e.to_string()))?;
let api_key = config
.get_embed_model_api_key()
.map_err(|e| crate::AgentError::Internal(e.to_string()))?;
let qdrant_url = config
.get_qdrant_url()
.map_err(|e| crate::AgentError::Internal(e.to_string()))?;
let qdrant_api_key = config.get_qdrant_api_key();
let openai = async_openai::Client::with_config(
OpenAIConfig::new()
.with_api_base(base_url)
.with_api_key(api_key),
);
let qdrant = QdrantClient::new(&qdrant_url, qdrant_api_key.as_deref()).await?;
Ok(EmbedClient::new(openai, qdrant))
}

312
libs/agent/embed/qdrant.rs Normal file
View File

@ -0,0 +1,312 @@
use qdrant_client::Qdrant;
use qdrant_client::qdrant::{
Condition, CreateCollectionBuilder, DeletePointsBuilder, Distance, FieldCondition, Filter,
Match, PointStruct, SearchPointsBuilder, UpsertPointsBuilder, VectorParamsBuilder, Vectors,
condition::ConditionOneOf, r#match::MatchValue, point_id::PointIdOptions, value,
};
use std::collections::HashMap;
use std::sync::Arc;
use super::client::{EmbedPayload, SearchResult};
use crate::embed::client::EmbedVector;
pub struct QdrantClient {
inner: Arc<Qdrant>,
}
impl Clone for QdrantClient {
fn clone(&self) -> Self {
Self {
inner: self.inner.clone(),
}
}
}
impl QdrantClient {
pub async fn new(url: &str, api_key: Option<&str>) -> crate::Result<Self> {
let mut builder = Qdrant::from_url(url);
if let Some(key) = api_key {
builder = builder.api_key(key);
}
let inner = builder
.build()
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(Self {
inner: Arc::new(inner),
})
}
fn collection_name(entity_type: &str) -> String {
format!("embed_{}", entity_type)
}
pub async fn ensure_collection(&self, entity_type: &str, dimensions: u64) -> crate::Result<()> {
let name = Self::collection_name(entity_type);
let exists = self
.inner
.collection_exists(&name)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
if exists {
return Ok(());
}
let create_collection = CreateCollectionBuilder::new(name)
.vectors_config(VectorParamsBuilder::new(dimensions, Distance::Cosine))
.build();
self.inner
.create_collection(create_collection)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(())
}
pub async fn upsert_points(&self, points: Vec<EmbedVector>) -> crate::Result<()> {
if points.is_empty() {
return Ok(());
}
let collection_name = Self::collection_name(&points[0].payload.entity_type);
let qdrant_points: Vec<PointStruct> = points
.into_iter()
.map(|p| {
let mut payload: HashMap<String, qdrant_client::qdrant::Value> = HashMap::new();
payload.insert("entity_type".to_string(), p.payload.entity_type.into());
payload.insert("entity_id".to_string(), p.payload.entity_id.into());
payload.insert("text".to_string(), p.payload.text.into());
if let Some(extra) = p.payload.extra {
let extra_str = serde_json::to_string(&extra).unwrap_or_default();
payload.insert(
"extra".to_string(),
qdrant_client::qdrant::Value {
kind: Some(
qdrant_client::qdrant::value::Kind::StringValue(extra_str),
),
},
);
}
PointStruct::new(p.id, Vectors::from(p.vector), payload)
})
.collect();
let upsert = UpsertPointsBuilder::new(collection_name, qdrant_points).build();
self.inner
.upsert_points(upsert)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(())
}
fn extract_string(value: &qdrant_client::qdrant::Value) -> String {
match &value.kind {
Some(value::Kind::StringValue(s)) => s.clone(),
_ => String::new(),
}
}
pub async fn search(
&self,
vector: &[f32],
entity_type: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
let collection_name = Self::collection_name(entity_type);
let search = SearchPointsBuilder::new(collection_name, vector.to_vec(), limit as u64)
.with_payload(true)
.build();
let results = self
.inner
.search_points(search)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(results
.result
.into_iter()
.filter_map(|p| {
let entity_type = p
.payload
.get(&"entity_type".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let entity_id = p
.payload
.get(&"entity_id".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let text = p
.payload
.get(&"text".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let extra = p
.payload
.get(&"extra".to_string())
.and_then(|v| Some(Self::extract_string(v)))
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let id =
p.id.and_then(|id| id.point_id_options)
.map(|opts| match opts {
PointIdOptions::Uuid(s) => s,
PointIdOptions::Num(n) => n.to_string(),
})
.unwrap_or_default();
Some(SearchResult {
id,
score: p.score,
payload: EmbedPayload {
entity_type,
entity_id,
text,
extra,
},
})
})
.collect())
}
pub async fn search_with_filter(
&self,
vector: &[f32],
entity_type: &str,
limit: usize,
filter: Filter,
) -> crate::Result<Vec<SearchResult>> {
let collection_name = Self::collection_name(entity_type);
let search = SearchPointsBuilder::new(collection_name, vector.to_vec(), limit as u64)
.with_payload(true)
.filter(filter)
.build();
let results = self
.inner
.search_points(search)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(results
.result
.into_iter()
.filter_map(|p| {
let entity_type = p
.payload
.get(&"entity_type".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let entity_id = p
.payload
.get(&"entity_id".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let text = p
.payload
.get(&"text".to_string())
.map(Self::extract_string)
.unwrap_or_default();
let extra = p
.payload
.get(&"extra".to_string())
.and_then(|v| Some(Self::extract_string(v)))
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let id =
p.id.and_then(|id| id.point_id_options)
.map(|opts| match opts {
PointIdOptions::Uuid(s) => s,
PointIdOptions::Num(n) => n.to_string(),
})
.unwrap_or_default();
Some(SearchResult {
id,
score: p.score,
payload: EmbedPayload {
entity_type,
entity_id,
text,
extra,
},
})
})
.collect())
}
pub async fn delete_by_filter(&self, entity_type: &str, entity_id: &str) -> crate::Result<()> {
let collection_name = Self::collection_name(entity_type);
let filter = Filter {
must: vec![Condition {
condition_one_of: Some(ConditionOneOf::Field(FieldCondition {
key: "entity_id".to_string(),
r#match: Some(Match {
match_value: Some(MatchValue::Keyword(entity_id.to_string())),
}),
..Default::default()
})),
}],
..Default::default()
};
let delete = DeletePointsBuilder::new(collection_name)
.points(filter)
.build();
self.inner
.delete_points(delete)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(())
}
pub async fn delete_collection(&self, entity_type: &str) -> crate::Result<()> {
let name = Self::collection_name(entity_type);
self.inner
.delete_collection(name)
.await
.map_err(|e| crate::AgentError::Qdrant(e.to_string()))?;
Ok(())
}
pub async fn ensure_memory_collection(&self, dimensions: u64) -> crate::Result<()> {
self.ensure_collection("memory", dimensions).await
}
pub async fn ensure_skill_collection(&self, dimensions: u64) -> crate::Result<()> {
self.ensure_collection("skill", dimensions).await
}
pub async fn search_memory(
&self,
vector: &[f32],
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.search(vector, "memory", limit).await
}
pub async fn search_skill(
&self,
vector: &[f32],
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.search(vector, "skill", limit).await
}
}

232
libs/agent/embed/service.rs Normal file
View File

@ -0,0 +1,232 @@
use async_trait::async_trait;
use qdrant_client::qdrant::Filter;
use sea_orm::DatabaseConnection;
use std::sync::Arc;
use super::client::{EmbedClient, EmbedPayload, EmbedVector, SearchResult};
#[async_trait]
pub trait Embeddable {
fn entity_type(&self) -> &'static str;
fn to_text(&self) -> String;
fn entity_id(&self) -> String;
}
pub struct EmbedService {
client: Arc<EmbedClient>,
db: DatabaseConnection,
model_name: String,
dimensions: u64,
}
impl EmbedService {
pub fn new(
client: EmbedClient,
db: DatabaseConnection,
model_name: String,
dimensions: u64,
) -> Self {
Self {
client: Arc::new(client),
db,
model_name,
dimensions,
}
}
pub async fn embed_issue(
&self,
id: &str,
title: &str,
body: Option<&str>,
) -> crate::Result<()> {
let text = match body {
Some(b) if !b.is_empty() => format!("{}\n\n{}", title, b),
_ => title.to_string(),
};
let vector = self.client.embed_text(&text, &self.model_name).await?;
let point = EmbedVector {
id: id.to_string(),
vector,
payload: EmbedPayload {
entity_type: "issue".to_string(),
entity_id: id.to_string(),
text,
extra: None,
},
};
self.client.upsert(vec![point]).await
}
pub async fn embed_repo(
&self,
id: &str,
name: &str,
description: Option<&str>,
) -> crate::Result<()> {
let text = match description {
Some(d) if !d.is_empty() => format!("{}: {}", name, d),
_ => name.to_string(),
};
let vector = self.client.embed_text(&text, &self.model_name).await?;
let point = EmbedVector {
id: id.to_string(),
vector,
payload: EmbedPayload {
entity_type: "repo".to_string(),
entity_id: id.to_string(),
text,
extra: None,
},
};
self.client.upsert(vec![point]).await
}
pub async fn embed_issues<T: Embeddable + Send + Sync>(
&self,
items: Vec<T>,
) -> crate::Result<()> {
if items.is_empty() {
return Ok(());
}
let texts: Vec<String> = items.iter().map(|i| i.to_text()).collect();
let embeddings = self.client.embed_batch(&texts, &self.model_name).await?;
let points: Vec<EmbedVector> = items
.into_iter()
.zip(embeddings.into_iter())
.map(|(item, vector)| EmbedVector {
id: item.entity_id(),
vector,
payload: EmbedPayload {
entity_type: item.entity_type().to_string(),
entity_id: item.entity_id(),
text: item.to_text(),
extra: None,
},
})
.collect();
self.client.upsert(points).await
}
pub async fn search_issues(
&self,
query: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.client
.search(query, "issue", &self.model_name, limit)
.await
}
pub async fn search_repos(
&self,
query: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.client
.search(query, "repo", &self.model_name, limit)
.await
}
pub async fn search_issues_filtered(
&self,
query: &str,
limit: usize,
filter: Filter,
) -> crate::Result<Vec<SearchResult>> {
self.client
.search_with_filter(query, "issue", &self.model_name, limit, filter)
.await
}
pub async fn delete_issue_embedding(&self, issue_id: &str) -> crate::Result<()> {
self.client.delete_by_entity_id("issue", issue_id).await
}
pub async fn delete_repo_embedding(&self, repo_id: &str) -> crate::Result<()> {
self.client.delete_by_entity_id("repo", repo_id).await
}
pub async fn ensure_collections(&self) -> crate::Result<()> {
self.client
.ensure_collection("issue", self.dimensions)
.await?;
self.client
.ensure_collection("repo", self.dimensions)
.await?;
self.client.ensure_skill_collection(self.dimensions).await?;
self.client.ensure_memory_collection(self.dimensions).await?;
Ok(())
}
pub fn db(&self) -> &DatabaseConnection {
&self.db
}
pub fn client(&self) -> &Arc<EmbedClient> {
&self.client
}
/// Embed a project skill into Qdrant for vector-based semantic search.
pub async fn embed_skill(
&self,
skill_id: i64,
name: &str,
description: Option<&str>,
content: &str,
project_uuid: &str,
) -> crate::Result<()> {
let desc = description.unwrap_or_default();
let id = skill_id.to_string();
self.client
.embed_skill(&id, name, desc, content, project_uuid)
.await
}
/// Search skills by semantic similarity within a project.
pub async fn search_skills(
&self,
query: &str,
project_uuid: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.client
.search_skills(query, &self.model_name, project_uuid, limit)
.await
}
/// Embed a conversation message into Qdrant as a memory vector.
pub async fn embed_memory(
&self,
message_id: i64,
text: &str,
room_id: &str,
user_id: Option<&str>,
) -> crate::Result<()> {
let id = message_id.to_string();
self.client
.embed_memory(&id, text, room_id, user_id)
.await
}
/// Search past conversation messages by semantic similarity within a room.
pub async fn search_memories(
&self,
query: &str,
room_id: &str,
limit: usize,
) -> crate::Result<Vec<SearchResult>> {
self.client
.search_memories(query, &self.model_name, room_id, limit)
.await
}
}

31
libs/agent/error.rs Normal file
View File

@ -0,0 +1,31 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AgentError {
#[error("openai error: {0}")]
OpenAi(String),
#[error("qdrant error: {0}")]
Qdrant(String),
#[error("internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, AgentError>;
impl From<async_openai::error::OpenAIError> for AgentError {
fn from(e: async_openai::error::OpenAIError) -> Self {
AgentError::OpenAi(e.to_string())
}
}
impl From<qdrant_client::QdrantError> for AgentError {
fn from(e: qdrant_client::QdrantError) -> Self {
AgentError::Qdrant(e.to_string())
}
}
impl From<sea_orm::DbErr> for AgentError {
fn from(e: sea_orm::DbErr) -> Self {
AgentError::Internal(e.to_string())
}
}

36
libs/agent/lib.rs Normal file
View File

@ -0,0 +1,36 @@
pub mod chat;
pub mod client;
pub mod compact;
pub mod embed;
pub mod error;
pub mod perception;
pub mod react;
pub mod task;
pub mod tokent;
pub mod tool;
pub use task::TaskService;
pub use tokent::{TokenUsage, resolve_usage};
pub use perception::{PerceptionService, SkillContext, SkillEntry, ToolCallEvent};
use async_openai::Client;
use async_openai::config::OpenAIConfig;
pub use chat::{
AiContextSenderType, AiRequest, AiStreamChunk, ChatService, Mention, RoomMessageContext,
StreamCallback,
};
pub use client::{AiCallResponse, AiClientConfig, call_with_params, call_with_retry};
pub use compact::{CompactConfig, CompactLevel, CompactService, CompactSummary, MessageSummary};
pub use embed::{EmbedClient, EmbedService, QdrantClient, SearchResult};
pub use error::{AgentError, Result};
pub use react::{
Hook, HookAction, NoopHook, ReactAgent, ReactConfig, ReactStep, ToolCallAction, TracingHook,
};
pub use tool::{
ToolCall, ToolCallResult, ToolContext, ToolDefinition, ToolError, ToolExecutor, ToolParam,
ToolRegistry, ToolResult, ToolSchema,
};
#[derive(Clone)]
pub struct AgentService {
pub client: Client<OpenAIConfig>,
}

View File

@ -0,0 +1,167 @@
//! Active skill awareness — proactive skill retrieval triggered by explicit user intent.
//!
//! The agent proactively loads a specific skill when the user explicitly references it
//! in their message. Patterns include:
//!
//! - Direct slug mention: "用 code-review", "使用 skill:code-review", "@code-review"
//! - Task-based invocation: "帮我 code review", "做一次 security scan"
//! - Intent keywords with skill context: "review 我的 PR", "scan for bugs"
//!
//! This is the highest-priority perception mode — if the user explicitly asks for a
//! skill, it always gets injected regardless of auto/passive scores.
use super::{SkillContext, SkillEntry};
use once_cell::sync::Lazy;
use regex::Regex;
/// Active skill awareness that detects explicit skill invocations in user messages.
#[derive(Debug, Clone, Default)]
pub struct ActiveSkillAwareness;
impl ActiveSkillAwareness {
pub fn new() -> Self {
Self
}
/// Detect if the user explicitly invoked a skill in their message.
///
/// Returns the first matching skill, or `None` if no explicit invocation is found.
///
/// Matching patterns:
/// - `用 <slug>` / `使用 <slug>` (Chinese: "use / apply <slug>")
/// - `skill:<slug>` (explicit namespace)
/// - `@<slug>` (GitHub-style mention)
/// - `帮我 <slug>` / `<name> 帮我` (Chinese: "help me <slug>")
/// - `做一次 <name>` / `进行一次 <name>` (Chinese: "do a <name>")
pub fn detect(&self, input: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
let input_lower = input.to_lowercase();
// Try each matching pattern in priority order.
if let Some(skill) = self.match_by_prefix_pattern(&input_lower, skills) {
return Some(skill);
}
// Try matching by skill name (for natural language invocations).
if let Some(skill) = self.match_by_name(&input_lower, skills) {
return Some(skill);
}
// Try matching by slug substring in the message.
self.match_by_slug_substring(&input_lower, skills)
}
/// Pattern: "用 code-review", "使用 skill:xxx", "@xxx", "skill:xxx"
fn match_by_prefix_pattern(&self, input: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
// Pattern 1: 英文 slug 前缀 "use ", "using ", "apply ", "with "
static USE_PAT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^\s*(?:use|using|apply|with)\s+([a-z0-9/_-]+)").unwrap());
if let Some(caps) = USE_PAT.captures(input) {
let slug = caps.get(1)?.as_str().trim();
return self.find_skill_by_slug(slug, skills);
}
// Pattern 2: skill:xxx
static SKILL_COLON_PAT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)skill\s*:\s*([a-z0-9/_-]+)").unwrap());
if let Some(caps) = SKILL_COLON_PAT.captures(input) {
let slug = caps.get(1)?.as_str().trim();
return self.find_skill_by_slug(slug, skills);
}
// Pattern 3: @xxx (mention style)
static AT_PAT: Lazy<Regex> =
Lazy::new(|| Regex::new(r"@([a-z0-9][a-z0-9_/-]*[a-z0-9])").unwrap());
if let Some(caps) = AT_PAT.captures(input) {
let slug = caps.get(1)?.as_str().trim();
return self.find_skill_by_slug(slug, skills);
}
// Pattern 4: 帮我 xxx, 做一个 xxx, 进行 xxx, 做 xxx
static ZH_PAT: Lazy<Regex> = Lazy::new(
|| Regex::new(r"(?ix)[\u4e00-\u9fff]+\s+(?:帮我|做一个|进行一次|做|使用|用)\s+([a-z0-9][a-z0-9_/-]{0,30})")
.unwrap(),
);
if let Some(caps) = ZH_PAT.captures(input) {
let slug_or_name = caps.get(1)?.as_str().trim();
return self
.find_skill_by_slug(slug_or_name, skills)
.or_else(|| self.find_skill_by_name(slug_or_name, skills));
}
None
}
/// Match by skill name in natural language (e.g., "code review" → "code-review")
fn match_by_name(&self, input: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
for skill in skills {
// Normalize skill name to a search pattern: "Code Review" -> "code review"
let name_lower = skill.name.to_lowercase();
// Direct substring match (the skill name appears in the input).
if input.contains(&name_lower) {
return Some(SkillContext {
label: format!("Active skill: {}", skill.name),
content: format!("# {} (actively invoked)\n\n{}", skill.name, skill.content),
});
}
// Try removing hyphens/underscores: "code-review" contains "code review"
let normalized_name = name_lower.replace(['-', '_'], " ");
if input.contains(&normalized_name) {
return Some(SkillContext {
label: format!("Active skill: {}", skill.name),
content: format!("# {} (actively invoked)\n\n{}", skill.name, skill.content),
});
}
}
None
}
/// Match by slug substring anywhere in the message.
fn match_by_slug_substring(&self, input: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
// Remove common command words to isolate the slug.
let cleaned = input
.replace("please ", "")
.replace("帮我", "")
.replace("帮我review", "")
.replace("帮我 code review", "")
.replace("帮我review", "");
for skill in skills {
let slug = skill.slug.to_lowercase();
// Check if the slug (or any segment of it) appears as a word.
if cleaned.contains(&slug) || slug.split('/').any(|seg| cleaned.contains(seg) && seg.len() > 3)
{
return Some(SkillContext {
label: format!("Active skill: {}", skill.name),
content: format!("# {} (actively invoked)\n\n{}", skill.name, skill.content),
});
}
}
None
}
fn find_skill_by_slug(&self, slug: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
let slug_lower = slug.to_lowercase();
skills.iter().find(|s| s.slug.to_lowercase() == slug_lower).map(|skill| {
SkillContext {
label: format!("Active skill: {}", skill.name),
content: format!("# {} (actively invoked)\n\n{}", skill.name, skill.content),
}
})
}
fn find_skill_by_name(&self, name: &str, skills: &[SkillEntry]) -> Option<SkillContext> {
let name_lower = name.to_lowercase();
skills.iter().find(|s| s.name.to_lowercase() == name_lower).map(|skill| {
SkillContext {
label: format!("Active skill: {}", skill.name),
content: format!("# {} (actively invoked)\n\n{}", skill.name, skill.content),
}
})
}
}

View File

@ -0,0 +1,178 @@
//! Auto skill awareness — background scanning for skill relevance.
//!
//! Periodically (or on-demand) scans the conversation context to identify
//! which enabled skills might be relevant, based on keyword overlap between
//! the skill's metadata (name, description, content snippets) and the
//! conversation text.
//!
//! This is the "ambient awareness" mode — the agent is always aware of
//! which skills might apply without the user explicitly invoking them.
use super::{SkillContext, SkillEntry};
/// Auto skill awareness config.
#[derive(Debug, Clone)]
pub struct AutoSkillAwareness {
/// Minimum keyword overlap score (0.01.0) to consider a skill relevant.
min_score: f32,
/// Maximum number of skills to inject via auto-awareness.
max_skills: usize,
}
impl Default for AutoSkillAwareness {
fn default() -> Self {
Self {
min_score: 0.15,
max_skills: 3,
}
}
}
impl AutoSkillAwareness {
pub fn new(min_score: f32, max_skills: usize) -> Self {
Self { min_score, max_skills }
}
/// Detect relevant skills by scoring keyword overlap between skill metadata
/// and the conversation text (current input + recent history).
///
/// Returns up to `max_skills` skills sorted by relevance score.
pub async fn detect(
&self,
current_input: &str,
history: &[String],
skills: &[SkillEntry],
) -> Vec<SkillContext> {
if skills.is_empty() {
return Vec::new();
}
// Build a combined corpus from current input and recent history (last 5 messages).
let history_text: String = history
.iter()
.rev()
.take(5)
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(" ");
let corpus = format!("{} {}", current_input, history_text).to_lowercase();
// Extract keywords from the corpus (split on whitespace + strip punctuation).
let corpus_keywords = Self::extract_keywords(&corpus);
if corpus_keywords.is_empty() {
return Vec::new();
}
// Score each skill.
let mut scored: Vec<_> = skills
.iter()
.map(|skill| {
let score = Self::score_skill(&corpus_keywords, skill);
(score, skill)
})
.filter(|(score, _)| *score >= self.min_score)
.collect();
// Sort descending by score.
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored
.into_iter()
.take(self.max_skills)
.map(|(_, skill)| {
// Extract a short relevant excerpt around the first keyword match.
let excerpt = Self::best_excerpt(&corpus, skill);
SkillContext {
label: format!("Auto skill: {}", skill.name),
content: excerpt,
}
})
.collect()
}
/// Extract meaningful keywords from text.
fn extract_keywords(text: &str) -> Vec<String> {
// Common English + Chinese stopwords to filter out.
const STOPWORDS: &[&str] = &[
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "can", "to", "of", "in", "for", "on", "with",
"at", "by", "from", "as", "or", "and", "but", "if", "not", "no", "so",
"this", "that", "these", "those", "it", "its", "i", "you", "he", "she",
"we", "they", "what", "which", "who", "when", "where", "why", "how",
"all", "each", "every", "both", "few", "more", "most", "other", "some",
"such", "only", "own", "same", "than", "too", "very", "just", "also",
"now", "here", "there", "then", "once", "again", "always", "ever",
"", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "比较",
];
text.split_whitespace()
.filter(|w| {
let w_clean = w.trim_matches(|c: char| !c.is_alphanumeric());
w_clean.len() >= 3 && !STOPWORDS.contains(&w_clean)
})
.map(|w| w.to_lowercase())
.collect()
}
/// Score a skill by keyword overlap between the corpus keywords and the skill's
/// name + description + content (first 500 chars).
fn score_skill(corpus_keywords: &[String], skill: &SkillEntry) -> f32 {
let skill_text = format!(
"{} {}",
skill.name,
skill.description.as_deref().unwrap_or("")
);
let skill_text = skill_text.to_lowercase();
let skill_keywords = Self::extract_keywords(&skill_text);
let content_sample = skill.content.chars().take(500).collect::<String>().to_lowercase();
let content_keywords = Self::extract_keywords(&content_sample);
let all_skill_keywords = [&skill_keywords[..], &content_keywords[..]].concat();
if all_skill_keywords.is_empty() {
return 0.0;
}
let overlap: usize = corpus_keywords
.iter()
.filter(|kw| all_skill_keywords.iter().any(|sk| sk.contains(kw.as_str()) || kw.as_str().contains(sk.as_str())))
.count();
overlap as f32 / all_skill_keywords.len().max(1) as f32
}
/// Extract the best excerpt from skill content — the paragraph most relevant to the corpus.
fn best_excerpt(corpus: &str, skill: &SkillEntry) -> String {
// Try to find a relevant paragraph: one that shares the most keywords with corpus.
let corpus_kws = Self::extract_keywords(corpus);
let best_para = skill
.content
.split('\n')
.filter(|para| !para.trim().is_empty())
.map(|para| {
let para_kws = Self::extract_keywords(&para.to_lowercase());
let overlap: usize = corpus_kws
.iter()
.filter(|kw| para_kws.iter().any(|pk| pk.contains(kw.as_str()) || kw.as_str().contains(pk.as_str())))
.count();
(overlap, para)
})
.filter(|(score, _)| *score > 0)
.max_by_key(|(score, _)| *score);
if let Some((_, para)) = best_para {
// Return the best paragraph with a header.
format!("# {} (auto-matched)\n\n{}", skill.name, para.trim())
} else {
// Fallback: use first 300 chars of content as excerpt.
let excerpt = skill.content.chars().take(300).collect::<String>();
format!("# {} (auto-matched)\n\n{}...", skill.name, excerpt.trim())
}
}
}

View File

@ -0,0 +1,131 @@
//! Skill perception system for the AI agent.
//!
//! Provides three perception modes for injecting relevant skills into the agent's context:
//!
//! - **Auto (自动感知)**: Background awareness that scans conversation content for skill
//! relevance based on keyword matching and semantic similarity.
//!
//! - **Active (主动感知)**: Proactive skill retrieval triggered by explicit user intent,
//! such as mentioning a skill slug directly in the message. Both keyword and vector-based.
//!
//! - **Passive (被动感知)**: Reactive skill retrieval triggered by tool-call events,
//! such as when the agent mentions a specific skill in its reasoning. Both keyword and
//! vector-based.
pub mod active;
pub mod auto;
pub mod passive;
pub mod vector;
pub use active::ActiveSkillAwareness;
pub use auto::AutoSkillAwareness;
pub use passive::PassiveSkillAwareness;
pub use vector::{VectorActiveAwareness, VectorPassiveAwareness};
use async_openai::types::chat::ChatCompletionRequestMessage;
/// A chunk of skill context ready to be injected into the message list.
#[derive(Debug, Clone)]
pub struct SkillContext {
/// Human-readable label shown to the AI, e.g. "Active skill: code-review"
pub label: String,
/// The actual skill content to inject.
pub content: String,
}
/// Converts skill context into a system message for injection.
impl SkillContext {
pub fn to_system_message(self) -> ChatCompletionRequestMessage {
use async_openai::types::chat::{
ChatCompletionRequestSystemMessage,
ChatCompletionRequestSystemMessageContent,
};
ChatCompletionRequestMessage::System(ChatCompletionRequestSystemMessage {
content: ChatCompletionRequestSystemMessageContent::Text(format!(
"[{}]\n{}",
self.label, self.content
)),
..Default::default()
})
}
}
/// Unified perception service combining all three modes.
#[derive(Debug, Clone)]
pub struct PerceptionService {
pub auto: AutoSkillAwareness,
pub active: ActiveSkillAwareness,
pub passive: PassiveSkillAwareness,
}
impl Default for PerceptionService {
fn default() -> Self {
Self {
auto: AutoSkillAwareness::default(),
active: ActiveSkillAwareness::default(),
passive: PassiveSkillAwareness::default(),
}
}
}
impl PerceptionService {
/// Inject relevant skill context into the message list based on current conversation state.
///
/// - **auto**: Scans the current input and conversation history for skill-relevant keywords
/// and injects matching skills that are enabled.
/// - **active**: Checks if the user explicitly invoked a skill by slug (e.g. "用 code-review")
/// and injects it.
/// - **passive**: Checks if any tool-call events or prior observations mention a skill
/// slug and injects the matching skill.
///
/// Returns a list of system messages to prepend to the conversation.
pub async fn inject_skills(
&self,
input: &str,
history: &[String],
tool_calls: &[ToolCallEvent],
enabled_skills: &[SkillEntry],
) -> Vec<SkillContext> {
let mut results = Vec::new();
// Active: explicit skill invocation (highest priority)
if let Some(skill) = self.active.detect(input, enabled_skills) {
results.push(skill);
}
// Passive: triggered by tool-call events
for tc in tool_calls {
if let Some(skill) = self.passive.detect(tc, enabled_skills) {
if !results.iter().any(|r: &SkillContext| r.label == skill.label) {
results.push(skill);
}
}
}
// Auto: keyword-based relevance matching
let auto_results = self.auto.detect(input, history, enabled_skills).await;
for skill in auto_results {
if !results.iter().any(|r: &SkillContext| r.label == skill.label) {
results.push(skill);
}
}
results
}
}
/// A tool-call event used for passive skill detection.
#[derive(Debug, Clone)]
pub struct ToolCallEvent {
pub tool_name: String,
pub arguments: String,
}
/// A skill entry from the database, used for matching.
#[derive(Debug, Clone)]
pub struct SkillEntry {
pub slug: String,
pub name: String,
pub description: Option<String>,
pub content: String,
}

Some files were not shown because too many files have changed in this diff Show More