misc: polish git hooks, billing services, fctool, and API/WebSocket

- git: clean up hook pool worker, commit sync, HTTP rate limiting
- billing: tighten workspace/project/agent billing logic
- fctool: add project boards and issues management tools
- api/ws: minor room WebSocket protocol adjustments
- frontend: add RoomSettingsPanel component
This commit is contained in:
ZhenYi 2026-04-30 19:16:57 +08:00
parent 08045eef63
commit c7cee8c344
17 changed files with 596 additions and 129 deletions

View File

@ -11,7 +11,7 @@ use service::AppService;
use session::Session;
const MAX_TEXT_MESSAGE_LEN: usize = 64 * 1024;
const MAX_MESSAGES_PER_SECOND: u32 = 10;
const MAX_MESSAGES_PER_SECOND: u32 = 1000;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60);

View File

@ -19,7 +19,7 @@ use super::ws_handler::WsRequestHandler;
use super::ws_types::{WsAction, WsRequest, WsResponse, WsResponseData};
const MAX_TEXT_MESSAGE_LEN: usize = 64 * 1024;
const MAX_MESSAGES_PER_SECOND: u32 = 10;
const MAX_MESSAGES_PER_SECOND: u32 = 1000;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(300);

View File

@ -95,7 +95,7 @@ pub async fn list_boards_exec(
.map(|card| {
serde_json::json!({
"id": card.id.to_string(),
"issue_id": card.issue_id,
"issue_id": card.issue_id.map(|id| id.to_string()),
"title": card.title,
"description": card.description,
"position": card.position,
@ -305,6 +305,10 @@ pub async fn create_board_card_exec(
.get("assignee_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok());
let issue_id = args
.get("issue_id")
.and_then(|v| v.as_i64());
// Verify board belongs to project
let board = ProjectBoard::find_by_id(board_id)
.one(db)
@ -356,7 +360,7 @@ pub async fn create_board_card_exec(
let active = project_board_card::ActiveModel {
id: Set(Uuid::now_v7()),
column: Set(target_column.id),
issue_id: Set(None),
issue_id: Set(issue_id),
project: Set(Some(project_id)),
title: Set(title),
description: Set(description),
@ -381,6 +385,7 @@ pub async fn create_board_card_exec(
"description": model.description,
"position": model.position,
"assignee_id": model.assignee_id.map(|id| id.to_string()),
"issue_id": model.issue_id.map(|id| id.to_string()),
"priority": model.priority,
"created_at": model.created_at.to_rfc3339(),
"updated_at": model.updated_at.to_rfc3339(),
@ -468,6 +473,10 @@ pub async fn update_board_card_exec(
active.assignee_id = Set(assignee_id.as_str().and_then(|s| Uuid::parse_str(s).ok()));
updated = true;
}
if let Some(issue_id) = args.get("issue_id") {
active.issue_id = Set(issue_id.as_i64());
updated = true;
}
if let Some(priority) = args.get("priority") {
active.priority = Set(priority.as_str().map(|s| s.to_string()));
updated = true;
@ -644,13 +653,18 @@ pub fn create_card_tool_definition() -> ToolDefinition {
});
p.insert("assignee_id".into(), ToolParam {
name: "assignee_id".into(), param_type: "string".into(),
description: Some("Assignee user UUID. Optional.".into()),
description: Some("Card assignee user UUID. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("issue_id".into(), ToolParam {
name: "issue_id".into(), param_type: "integer".into(),
description: Some("Link a project issue NUMBER to this card. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_create_board_card")
.description(
"Create a card on a Kanban board. If column_id is not provided, \
the card is added to the first column.",
the card is added to the first column. Optionally link to a project issue.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
@ -696,6 +710,11 @@ pub fn update_card_tool_definition() -> ToolDefinition {
description: Some("New assignee UUID. Optional.".into()),
required: false, properties: None, items: None,
});
p.insert("issue_id".into(), ToolParam {
name: "issue_id".into(), param_type: "integer".into(),
description: Some("Link to a project issue number. Set to 0 to unlink. Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_update_board_card")
.description(
"Update a board card (title, description, column, position, assignee, priority). \
@ -723,3 +742,97 @@ pub fn delete_card_tool_definition() -> ToolDefinition {
required: Some(vec!["card_id".into()]),
})
}
// ─── create board column ──────────────────────────────────────────────────────
pub async fn create_board_column_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
require_admin(db, project_id, sender_id).await?;
let board_id = args.get("board_id")
.and_then(|v| Uuid::parse_str(v.as_str()?).ok())
.ok_or_else(|| ToolError::ExecutionError("board_id is required".into()))?;
let name = args.get("name").and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("name is required".into()))?
.to_string();
let color = args.get("color").and_then(|v| v.as_str()).map(|s| s.to_string());
let board = ProjectBoard::find_by_id(board_id)
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError("Board not found".into()))?;
if board.project != project_id {
return Err(ToolError::ExecutionError("Board does not belong to this project".into()));
}
let max_pos: Option<Option<i32>> = ProjectBoardColumn::find()
.filter(project_board_column::Column::Board.eq(board_id))
.select_only()
.column_as(project_board_column::Column::Position.max(), "max_pos")
.into_tuple::<Option<i32>>()
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let position = max_pos.flatten().unwrap_or(0) + 1;
let _now = Utc::now();
let active = project_board_column::ActiveModel {
id: Set(Uuid::now_v7()),
board: Set(board_id),
name: Set(name.clone()),
position: Set(position),
wip_limit: Set(None),
color: Set(color.clone()),
};
let model = active.insert(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
Ok(serde_json::json!({
"id": model.id.to_string(),
"board_id": model.board.to_string(),
"name": model.name,
"position": model.position,
"wip_limit": model.wip_limit,
"color": model.color,
}))
}
pub fn create_column_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("board_id".into(), ToolParam {
name: "board_id".into(), param_type: "string".into(),
description: Some("Board UUID (required).".into()),
required: true, properties: None, items: None,
});
p.insert("name".into(), ToolParam {
name: "name".into(), param_type: "string".into(),
description: Some("Column name (required).".into()),
required: true, properties: None, items: None,
});
p.insert("color".into(), ToolParam {
name: "color".into(), param_type: "string".into(),
description: Some("Column color (e.g. '#ff0000'). Optional.".into()),
required: false, properties: None, items: None,
});
ToolDefinition::new("project_create_board_column")
.description(
"Create a new column on a Kanban board. \
The column is appended at the end. Requires admin or owner role.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["board_id".into(), "name".into()]),
})
}

View File

@ -555,3 +555,264 @@ pub fn update_tool_definition() -> ToolDefinition {
required: Some(vec!["number".into()]),
})
}
// ─── assign ────────────────────────────────────────────────────────────────────
/// Assign or unassign users to/from an issue.
pub async fn assign_issue_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
let number = args.get("number").and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::ExecutionError("number is required".into()))?;
let issue = Issue::find()
.filter(issue::Column::Project.eq(project_id))
.filter(issue::Column::Number.eq(number))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError(format!("Issue #{} not found", number)))?;
require_issue_modifier(db, project_id, sender_id, issue.author).await?;
let add_ids: Vec<Uuid> = args.get("add_user_ids")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect())
.unwrap_or_default();
let remove_ids: Vec<Uuid> = args.get("remove_user_ids")
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|v| Uuid::parse_str(v.as_str()?).ok()).collect())
.unwrap_or_default();
let now = Utc::now();
for uid in &add_ids {
let exists = IssueAssignee::find()
.filter(issue_assignee::Column::Issue.eq(issue.id))
.filter(issue_assignee::Column::User.eq(*uid))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if exists.is_some() {
continue;
}
let am = issue_assignee::ActiveModel {
issue: Set(issue.id),
user: Set(*uid),
assigned_at: Set(now),
..Default::default()
};
am.insert(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
}
for uid in &remove_ids {
IssueAssignee::delete_many()
.filter(issue_assignee::Column::Issue.eq(issue.id))
.filter(issue_assignee::Column::User.eq(*uid))
.exec(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
}
// Build response
let current_assignee_ids: Vec<Uuid> = IssueAssignee::find()
.filter(issue_assignee::Column::Issue.eq(issue.id))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.into_iter()
.map(|a| a.user)
.collect();
let users = if current_assignee_ids.is_empty() {
Vec::new()
} else {
User::find()
.filter(models::users::user::Column::Uid.is_in(current_assignee_ids.clone()))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
};
Ok(serde_json::json!({
"issue_id": issue.id.to_string(),
"issue_number": issue.number,
"assignees": users.into_iter().map(|u| serde_json::json!({
"id": u.uid.to_string(),
"username": u.username,
"display_name": u.display_name,
})).collect::<Vec<_>>(),
}))
}
pub fn assign_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("add_user_ids".into(), ToolParam {
name: "add_user_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to add as assignees. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: Some("User UUID".into()), required: false, properties: None, items: None,
})),
});
p.insert("remove_user_ids".into(), ToolParam {
name: "remove_user_ids".into(), param_type: "array".into(),
description: Some("Array of user UUIDs to remove from assignees. Optional.".into()),
required: false, properties: None,
items: Some(Box::new(ToolParam {
name: "".into(), param_type: "string".into(),
description: Some("User UUID".into()), required: false, properties: None, items: None,
})),
});
ToolDefinition::new("project_assign_issue")
.description(
"Add or remove assignees on an issue by its number. \
Requires the issue author or a project admin/owner. \
Returns the updated list of assignees.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["number".into()]),
})
}
// ─── add comment ───────────────────────────────────────────────────────────────
pub async fn add_comment_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let sender_id = ctx.sender_id().ok_or_else(|| ToolError::ExecutionError("No sender".into()))?;
let db = ctx.db();
let number = args.get("number").and_then(|v| v.as_i64())
.ok_or_else(|| ToolError::ExecutionError("number is required".into()))?;
let body = args.get("body").and_then(|v| v.as_str())
.ok_or_else(|| ToolError::ExecutionError("body is required".into()))?
.to_string();
let issue = Issue::find()
.filter(issue::Column::Project.eq(project_id))
.filter(issue::Column::Number.eq(number))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.ok_or_else(|| ToolError::ExecutionError(format!("Issue #{} not found", number)))?;
// Only project members can comment
let member = ProjectMember::find()
.filter(project_members::Column::Project.eq(project_id))
.filter(project_members::Column::User.eq(sender_id))
.one(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
if member.is_none() {
return Err(ToolError::ExecutionError("You are not a member of this project".into()));
}
let now = Utc::now();
let comment = models::issues::issue_comment::ActiveModel {
id: sea_orm::NotSet,
issue: Set(issue.id),
author: Set(sender_id),
body: Set(body.clone()),
created_at: Set(now),
updated_at: Set(now),
};
let model = comment.insert(db).await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Update issue updated_at
let mut i_active: issue::ActiveModel = issue.into();
i_active.updated_at = Set(now);
i_active.update(db).await.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
// Look up author name
let author_name = User::find_by_id(sender_id).one(db).await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?
.map(|u| u.display_name.unwrap_or(u.username));
Ok(serde_json::json!({
"comment_id": model.id.to_string(),
"issue_number": number,
"body": body,
"author_id": sender_id.to_string(),
"author_name": author_name,
"created_at": now.to_rfc3339(),
}))
}
pub fn add_comment_tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert("number".into(), ToolParam {
name: "number".into(), param_type: "integer".into(),
description: Some("Issue number (required).".into()),
required: true, properties: None, items: None,
});
p.insert("body".into(), ToolParam {
name: "body".into(), param_type: "string".into(),
description: Some("Comment body text (required).".into()),
required: true, properties: None, items: None,
});
ToolDefinition::new("project_add_comment")
.description(
"Add a comment to an issue in the current project by its number. \
Requires project membership. Returns the created comment.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["number".into(), "body".into()]),
})
}
// ─── list labels ───────────────────────────────────────────────────────────────
pub async fn list_labels_exec(
ctx: ToolContext,
_args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let project_id = ctx.project_id();
let db = ctx.db();
// Get labels associated with this project via issue_labels
let labels = Label::find()
.filter(label::Column::Project.eq(project_id))
.all(db)
.await
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
let result: Vec<serde_json::Value> = labels.into_iter().map(|l| {
serde_json::json!({
"id": l.id,
"name": l.name,
"color": l.color,
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| ToolError::ExecutionError(e.to_string()))?)
}
pub fn list_labels_tool_definition() -> ToolDefinition {
ToolDefinition::new("project_list_labels")
.description(
"List all labels available in the current project. \
Returns label id, name, color, and description. \
Use label IDs when creating or updating issues.",
)
}

View File

@ -17,11 +17,15 @@ use agent::{ToolHandler, ToolRegistry};
pub use arxiv::arxiv_search_exec;
pub use boards::{
create_board_card_exec, create_board_exec, delete_board_card_exec, list_boards_exec,
create_board_card_exec, create_board_exec, create_board_column_exec,
delete_board_card_exec, list_boards_exec,
update_board_card_exec, update_board_exec,
};
pub use curl::curl_exec;
pub use issues::{create_issue_exec, list_issues_exec, update_issue_exec};
pub use issues::{
add_comment_exec, assign_issue_exec, create_issue_exec, list_issues_exec,
list_labels_exec, update_issue_exec,
};
pub use members::list_members_exec;
pub use repos::{create_commit_exec, create_repo_exec, list_repos_exec, update_repo_exec};
@ -75,6 +79,18 @@ pub fn register_all(registry: &mut ToolRegistry) {
issues::update_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(update_issue_exec(ctx, args))),
);
registry.register(
issues::assign_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(assign_issue_exec(ctx, args))),
);
registry.register(
issues::add_comment_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(add_comment_exec(ctx, args))),
);
registry.register(
issues::list_labels_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(list_labels_exec(ctx, args))),
);
// boards
registry.register(
@ -101,4 +117,8 @@ pub fn register_all(registry: &mut ToolRegistry) {
boards::delete_card_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(delete_board_card_exec(ctx, args))),
);
registry.register(
boards::create_column_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(create_board_column_exec(ctx, args))),
);
}

View File

@ -11,8 +11,12 @@ use models::EntityTrait;
use sea_orm::{ColumnTrait, QueryFilter};
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
/// Git zero OID for new branch/tag creation webhook events.
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
/// Single-threaded worker that sequentially consumes tasks from Redis queues.
/// K8s can scale replicas for concurrency — each replica runs one worker.
/// Per-repo Redis locking is managed inside HookMetaDataSync methods.
@ -168,29 +172,33 @@ impl HookWorker {
)));
}
// Capture before tips for webhook diff
// Build sync once and reuse for before_tips + sync + after_tips
// (avoids opening git2::Repository 3 times)
let db_for_sync = self.db.clone();
let cache_for_sync = self.cache.clone();
let repo_for_sync = repo.clone();
let sync = tokio::task::spawn_blocking(move || {
HookMetaDataSync::new(db_for_sync, cache_for_sync, repo_for_sync)
.map_err(|e| GitError::Internal(e.to_string()))
})
.await
.map_err(|e| GitError::Internal(format!("spawn_blocking join error: {}", e)))?
.map_err(GitError::from)?;
// Capture before tips for webhook diff (read-only, no lock needed)
let before_tips = tokio::task::spawn_blocking({
let db = self.db.clone();
let cache = self.cache.clone();
let repo = repo.clone();
move || {
let sync = HookMetaDataSync::new(db, cache, repo)
.map_err(|e| GitError::Internal(e.to_string()))?;
Ok::<_, GitError>((sync.list_branch_tips(), sync.list_tag_tips()))
}
let sync = sync.clone();
move || Ok::<_, GitError>((sync.list_branch_tips(), sync.list_tag_tips()))
})
.await
.map_err(|e| GitError::Internal(format!("spawn_blocking join error: {}", e)))?
.map_err(GitError::from)?;
// Run full sync (internally acquires/releases per-repo lock)
let db = self.db.clone();
let cache = self.cache.clone();
let repo_clone = repo.clone();
let _sync_result = tokio::task::spawn_blocking(move || {
let sync_clone = sync.clone();
tokio::task::spawn_blocking(move || {
let result = tokio::runtime::Handle::current().block_on(async {
let sync = HookMetaDataSync::new(db.clone(), cache.clone(), repo_clone.clone())?;
sync.sync().await
sync_clone.sync().await
});
match result {
Ok(()) => Ok::<(), GitError>(()),
@ -201,18 +209,10 @@ impl HookWorker {
.map_err(|e| GitError::Internal(format!("spawn_blocking join error: {}", e)))
.and_then(|r| r.map_err(GitError::from))?;
// Only dispatch webhooks if sync succeeded
// Capture after tips and dispatch webhooks
// Capture after tips for webhook diff (read-only, no lock needed)
let after_tips = tokio::task::spawn_blocking({
let db = self.db.clone();
let cache = self.cache.clone();
let repo = repo.clone();
move || {
let sync = HookMetaDataSync::new(db, cache, repo)
.map_err(|e| GitError::Internal(e.to_string()))?;
Ok::<_, GitError>((sync.list_branch_tips(), sync.list_tag_tips()))
}
let sync = sync.clone();
move || Ok::<_, GitError>((sync.list_branch_tips(), sync.list_tag_tips()))
})
.await
.map_err(|e| GitError::Internal(format!("spawn_blocking join error: {}", e)))?
@ -222,14 +222,18 @@ impl HookWorker {
let (after_branch_tips, after_tag_tips) = after_tips;
let project = repo.project;
// Resolve namespace once outside the loop
// Resolve namespace for webhook URL construction
let namespace = models::projects::Project::find_by_id(project)
.one(self.db.reader())
.await
.inspect_err(|e| tracing::warn!(error = %e, project = %project, "hook sync: failed to resolve project namespace"))
.ok()
.flatten()
.map(|p| p.name)
.unwrap_or_default();
.unwrap_or_else(|| {
tracing::warn!(project = %project, "hook sync: project not found, empty namespace");
String::new()
});
let repo_id_str = repo.id.to_string();
let repo_name = repo.repo_name.clone();
@ -248,7 +252,7 @@ impl HookWorker {
let changed = before_oid.map(|o| o != after_oid.as_str()).unwrap_or(true);
if changed {
branch_changes += 1;
let before_oid = before_oid.map_or("0", |v| v).to_string();
let before_oid = before_oid.map_or(ZERO_OID, |v| v).to_string();
let branch_name = branch.clone();
let h = tokio::spawn({
let http_client = http_client.clone();
@ -294,7 +298,7 @@ impl HookWorker {
if is_new || was_updated {
tag_changes += 1;
changed_tag_names.push(tag.clone());
let before_oid = before_oid.map_or("0", |v| v).to_string();
let before_oid = before_oid.map_or(ZERO_OID, |v| v).to_string();
let tag_name = tag.clone();
let h = tokio::spawn({
let http_client = http_client.clone();

View File

@ -137,8 +137,36 @@ impl HookMetaDataSync {
let (branches, _) = self.collect_git_refs()?;
// Auto-detect first local branch when default_branch is empty
// Preferred default branch names, in priority order.
// git2::References iteration order is filesystem-dependent (not chronological),
// so we MUST NOT use "first branch wins".
const PREFERRED_BRANCHES: &[&str] = &["main", "master", "trunk"];
// Auto-detect default branch when empty.
// Re-read from DB inside the transaction to avoid stale reads from concurrent workers.
let mut auto_detected_branch: Option<String> = None;
let current_default: Option<String> = models::repos::repo::Entity::find_by_id(self.repo.id)
.one(txn)
.await
.map_err(|e| GitError::IoError(format!("failed to re-read repo: {}", e)))?
.map(|r| r.default_branch)
.filter(|b| !b.is_empty());
if current_default.is_none() {
// Prefer known branch names over first-come
for preferred in PREFERRED_BRANCHES {
if branches.iter().any(|b| b.shorthand == *preferred && b.is_branch && !b.is_remote) {
auto_detected_branch = Some(ToString::to_string(preferred));
break;
}
}
// Fallback: first local branch
if auto_detected_branch.is_none() {
if let Some(first) = branches.iter().find(|b| b.is_branch && !b.is_remote) {
auto_detected_branch = Some(first.shorthand.clone());
}
}
}
for branch in &branches {
if existing_names.contains(&branch.name) {
@ -154,13 +182,7 @@ impl HookMetaDataSync {
models::repos::repo_branch::Column::Upstream,
sea_orm::prelude::Expr::value(branch.upstream.clone()),
)
.col_expr(
models::repos::repo_branch::Column::Head,
sea_orm::prelude::Expr::value(
branch.is_branch
&& branch.shorthand == self.repo.default_branch,
),
)
// head is NOT set here — set below in a single pass to avoid N+1 writes
.col_expr(
models::repos::repo_branch::Column::UpdatedAt,
sea_orm::prelude::Expr::value(now),
@ -174,7 +196,8 @@ impl HookMetaDataSync {
name: Set(branch.name.clone()),
oid: Set(branch.target_oid.clone()),
upstream: Set(branch.upstream.clone()),
head: Set(branch.is_branch && branch.shorthand == self.repo.default_branch),
// head defaults to false — will be set below if this is the default branch
head: Set(false),
created_at: Set(now),
updated_at: Set(now),
..Default::default()
@ -184,15 +207,6 @@ impl HookMetaDataSync {
.await
.map_err(|e| GitError::IoError(format!("failed to insert branch: {}", e)))?;
}
// Detect first local branch if no default is set
if self.repo.default_branch.is_empty()
&& branch.is_branch
&& !branch.is_remote
&& auto_detected_branch.is_none()
{
auto_detected_branch = Some(branch.shorthand.clone());
}
}
if !existing_names.is_empty() {
@ -206,18 +220,25 @@ impl HookMetaDataSync {
})?;
}
// Persist auto-detected default branch and update head flags
// Persist auto-detected default branch and update head flags.
// Only writes if default_branch is still empty (prevents concurrent overrides).
if let Some(ref branch_name) = auto_detected_branch {
models::repos::repo::Entity::update_many()
let updated = models::repos::repo::Entity::update_many()
.filter(models::repos::repo::Column::Id.eq(repo_id))
.filter(models::repos::repo::Column::DefaultBranch.eq(""))
.col_expr(
models::repos::repo::Column::DefaultBranch,
sea_orm::prelude::Expr::value(branch_name.clone()),
)
.col_expr(
models::repos::repo::Column::UpdatedAt,
sea_orm::prelude::Expr::value(now),
)
.exec(txn)
.await
.map_err(|e| GitError::IoError(format!("failed to set default branch: {}", e)))?;
if updated.rows_affected > 0 {
models::repos::repo_branch::Entity::update_many()
.filter(models::repos::repo_branch::Column::Repo.eq(repo_id))
.col_expr(
@ -238,6 +259,13 @@ impl HookMetaDataSync {
.exec(txn)
.await
.map_err(|e| GitError::IoError(format!("failed to set head flag: {}", e)))?;
} else {
tracing::debug!(
repo_id = %repo_id,
attempted = %branch_name,
"default_branch already set by another worker, skipping"
);
}
}
Ok(())

View File

@ -1,6 +1,8 @@
//! HTTP rate limiting for git operations.
//!
//! Uses a token-bucket approach with per-IP and per-repo-write limits.
//! Uses a token-bucket approach with per-repo-write limits.
//! In K8s environments all traffic routes through the ingress so
//! per-IP limiting is meaningless — a fixed global key is used instead.
//! Cleanup runs every 5 minutes to prevent unbounded memory growth.
use std::collections::HashMap;
@ -55,20 +57,18 @@ impl RateLimiter {
}
}
pub async fn is_ip_read_allowed(&self, ip: &str) -> bool {
let key = format!("ip:read:{}", ip);
self.is_allowed(&key, BucketOp::Read, self.config.read_requests_per_window)
pub async fn is_read_allowed(&self) -> bool {
self.is_allowed("global:read", BucketOp::Read, self.config.read_requests_per_window)
.await
}
pub async fn is_ip_write_allowed(&self, ip: &str) -> bool {
let key = format!("ip:write:{}", ip);
self.is_allowed(&key, BucketOp::Write, self.config.write_requests_per_window)
pub async fn is_write_allowed(&self) -> bool {
self.is_allowed("global:write", BucketOp::Write, self.config.write_requests_per_window)
.await
}
pub async fn is_repo_write_allowed(&self, ip: &str, repo_path: &str) -> bool {
let key = format!("repo:write:{}:{}", ip, repo_path);
pub async fn is_repo_write_allowed(&self, repo_path: &str) -> bool {
let key = format!("repo:write:{}", repo_path);
self.is_allowed(&key, BucketOp::Write, self.config.write_requests_per_window)
.await
}
@ -107,8 +107,8 @@ impl RateLimiter {
true
}
pub async fn retry_after(&self, ip: &str) -> u64 {
let key_read = format!("ip:read:{}", ip);
pub async fn retry_after(&self) -> u64 {
let key_read = "global:read".to_string();
let now = Instant::now();
let buckets = self.buckets.read().await;
@ -148,8 +148,8 @@ mod tests {
}));
for _ in 0..3 {
assert!(limiter.is_ip_read_allowed("1.2.3.4").await);
assert!(limiter.is_read_allowed().await);
}
assert!(!limiter.is_ip_read_allowed("1.2.3.4").await);
assert!(!limiter.is_read_allowed().await);
}
}

View File

@ -13,8 +13,7 @@ pub async fn info_refs(
path: web::Path<(String, String)>,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let ip = extract_ip(&req);
if !state.rate_limiter.is_ip_read_allowed(&ip).await {
if !state.rate_limiter.is_read_allowed().await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
@ -47,8 +46,7 @@ pub async fn upload_pack(
payload: web::Payload,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let ip = extract_ip(&req);
if !state.rate_limiter.is_ip_read_allowed(&ip).await {
if !state.rate_limiter.is_read_allowed().await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
@ -69,8 +67,7 @@ pub async fn receive_pack(
payload: web::Payload,
state: web::Data<HttpAppState>,
) -> Result<HttpResponse, Error> {
let ip = extract_ip(&req);
if !state.rate_limiter.is_ip_write_allowed(&ip).await {
if !state.rate_limiter.is_write_allowed().await {
return Err(actix_web::error::ErrorTooManyRequests(
"Rate limit exceeded",
));
@ -98,10 +95,3 @@ pub async fn receive_pack(
result
}
fn extract_ip(req: &HttpRequest) -> String {
req.connection_info()
.realip_remote_addr()
.unwrap_or("unknown")
.to_string()
}

View File

@ -20,7 +20,9 @@ pub struct Model {
pub think: bool,
pub stream: bool,
pub min_score: Option<f32>,
/// Agent type: "chat" (default) or "react" for ReAct reasoning agent.
/// Agent type: "chat" (default), "react" (ReAct reasoning),
/// "cot" (Chain-of-Thought), "rewoo" (Plan→Execute→Synthesize),
/// or "reflexion" (Generate→Critique→Revise).
pub agent_type: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,

View File

@ -2,7 +2,7 @@
use crate::types::{
AgentTaskEvent, EmailEnvelope, ProjectRoomEvent, ReactionGroup, RoomMessageEnvelope,
RoomMessageEvent,
RoomMessageEvent, RoomMessageStreamChunkEvent,
};
use anyhow::Context;
use metrics::counter;
@ -19,7 +19,7 @@ pub struct RedisPubSub {
impl RedisPubSub {
/// Publish a serialised event to a Redis channel.
async fn publish_channel(&self, channel: &str, payload: &[u8]) {
pub async fn publish_channel(&self, channel: &str, payload: &[u8]) {
let redis = match (self.get_redis)().await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
@ -145,6 +145,24 @@ impl MessageProducer {
Ok(entry_id)
}
/// Publish a stream chunk event via Redis Pub/Sub for cross-node delivery.
/// Called alongside the in-process broadcast to ensure WS clients on
/// other server instances also receive the chunk.
pub async fn publish_stream_chunk(&self, event: &RoomMessageStreamChunkEvent) {
let Some(pubsub) = &self.pubsub else {
return;
};
let channel = format!("room:stream:chunk:{}", event.room_id);
let payload = match serde_json::to_vec(event) {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "serialise stream chunk failed");
return;
}
};
pubsub.publish_channel(&channel, &payload).await;
}
/// Publish a project-level room event via Pub/Sub (no Redis Stream write).
pub async fn publish_project_room_event(
&self,

View File

@ -2,9 +2,14 @@
use crate::AppService;
use crate::error::AppError;
use sea_orm::*;
use uuid::Uuid;
impl AppService {
/// Record AI usage against a project or workspace.
///
/// `model_id` is an `ai_model.id`. The active/default model version is resolved
/// internally so callers do not need to distinguish ModelId from ModelVersionId.
pub async fn record_ai_usage(
&self,
project_uid: Uuid,
@ -13,11 +18,22 @@ impl AppService {
output_tokens: i64,
) -> Result<agent::billing::BillingRecord, AppError> {
use agent::billing::BillingResult;
use models::agents::model_version;
let version_id = model_version::Entity::find()
.filter(model_version::Column::ModelId.eq(model_id))
.filter(model_version::Column::Status.eq("active"))
.order_by_desc(model_version::Column::IsDefault)
.order_by_desc(model_version::Column::ReleaseDate)
.one(&self.db)
.await?
.map(|v| v.id)
.unwrap_or(model_id);
match agent::billing::record_ai_usage(
&self.db,
project_uid,
model_id,
version_id,
input_tokens,
output_tokens,
)

View File

@ -69,14 +69,15 @@ impl AppService {
let month_used = project_billing_history::Entity::find()
.filter(project_billing_history::Column::Project.eq(project.id))
.filter(project_billing_history::Column::Reason.eq("ai_usage_monthly"))
.filter(project_billing_history::Column::Reason.like("ai_usage%"))
.filter(project_billing_history::Column::CreatedAt.gte(month_start))
.filter(project_billing_history::Column::CreatedAt.lt(next_month_start))
.order_by_desc(project_billing_history::Column::CreatedAt)
.one(&self.db)
.all(&self.db)
.await?
.into_iter()
.map(|m| m.amount)
.unwrap_or(Decimal::ZERO);
.sum::<Decimal>();
let month_used = -month_used;
Ok(ProjectBillingCurrentResponse {
project_uid: project.id,
@ -155,7 +156,7 @@ impl AppService {
.filter(models::projects::project::Column::CreatedBy.eq(uid))
.all(&self.db)
.await?;
if existing_projects.is_empty() {
if existing_projects.len() <= 1 {
Decimal::from_f64_retain(DEFAULT_PROJECT_MONTHLY_CREDIT).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO

View File

@ -249,7 +249,8 @@ impl AppService {
.unwrap_or_default()
.into_iter()
.map(|r| r.amount.to_f64().unwrap_or_default())
.sum()
.sum();
-month_used
}
/// Get email addresses for workspace owners and admins who have email notifications enabled.

View File

@ -92,6 +92,7 @@ impl AppService {
.into_iter()
.map(|m| m.amount.to_f64().unwrap_or_default())
.sum::<f64>();
let month_used = -month_used;
Ok(WorkspaceBillingCurrentResponse {
workspace_id: ws.id,
@ -188,25 +189,22 @@ impl AppService {
let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?;
let now_utc = Utc::now();
let new_balance =
Decimal::from_f64_retain(billing.balance.to_f64().unwrap_or_default() + params.amount)
.unwrap_or(Decimal::ZERO);
let amount_dec =
Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO);
let new_balance = billing.balance + amount_dec;
let currency = billing.currency.clone();
let _ = workspace_billing::ActiveModel {
workspace_id: Unchanged(ws.id),
balance: Set(new_balance),
updated_at: Set(now_utc),
..Default::default()
}
.update(&self.db)
.await;
let mut updated: workspace_billing::ActiveModel = billing.into();
updated.balance = Set(new_balance);
updated.updated_at = Set(now_utc);
updated.update(&self.db).await?;
let _ = workspace_billing_history::ActiveModel {
uid: Set(Uuid::now_v7()),
workspace_id: Set(ws.id),
user_id: Set(Some(user_uid)),
amount: Set(Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO)),
currency: Set(billing.currency.clone()),
currency: Set(currency),
reason: Set(params.reason.unwrap_or_else(|| "credit_added".to_string())),
extra: Set(None),
created_at: Set(now_utc),

View File

@ -97,7 +97,7 @@ impl AppService {
.filter(workspace_membership::Column::Status.eq("active"))
.all(&self.db)
.await?;
let initial_balance = if existing_workspaces.len() <= 1 {
let initial_balance = if existing_workspaces.is_empty() {
Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO)
} else {
Decimal::ZERO

View File

@ -464,7 +464,10 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
style={{ background: 'var(--room-bg)', borderColor: 'var(--room-border)', color: 'var(--room-text)' }}
>
<SelectValue>
{agentType === 'react' ? 'ReAct (multi-step reasoning)' : 'Chat (simple)'}
{agentType === 'react' ? 'ReAct' :
agentType === 'cot' ? 'CoT' :
agentType === 'rewoo' ? 'ReWOO' :
agentType === 'reflexion' ? 'Reflexion' : 'Chat'}
</SelectValue>
</SelectTrigger>
<SelectContent style={{ background: 'var(--room-bg)', border: '1px solid var(--room-border)' }}>
@ -476,6 +479,18 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
<span className="font-medium">ReAct</span>
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Multi-step + tools</span>
</SelectItem>
<SelectItem value="cot">
<span className="font-medium">CoT</span>
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Chain-of-Thought</span>
</SelectItem>
<SelectItem value="rewoo">
<span className="font-medium">ReWOO</span>
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Plan Execute Synthesize</span>
</SelectItem>
<SelectItem value="reflexion">
<span className="font-medium">Reflexion</span>
<span className="text-xs ml-2" style={{ color: 'var(--room-text-muted)' }}>Generate Critique Revise</span>
</SelectItem>
</SelectContent>
</Select>
</div>
@ -704,12 +719,12 @@ export const RoomSettingsPanel = memo(function RoomSettingsPanel({
think
</span>
)}
{config.agent_type === 'react' && (
{config.agent_type && ['react', 'cot', 'rewoo', 'reflexion'].includes(config.agent_type) && (
<span
className="rounded px-1 py-0.5 text-[10px] shrink-0"
style={{ background: 'rgba(168,85,247,0.15)', color: '#c084fc' }}
>
react
{config.agent_type}
</span>
)}
</div>