Compare commits

...

14 Commits

Author SHA1 Message Date
ZhenYi
6d8076674f chore(migrate): remove old room_compact_summary Rust migration module 2026-05-18 20:45:15 +08:00
ZhenYi
c39ee1ce2a refactor(ui): update shared layout and UI components for new theme system
Update Header, ProjectMessageFavoritesDrawer, RepoHeader, sheet,
EmptyState, ErrorState, LoadingState, PageHeader. Add ConfirmDialog
component.
2026-05-18 20:45:00 +08:00
ZhenYi
16865117de refactor(ui): update project and channel layouts for new theme system
Update channel layout, project layout, BoardHeader, and
IssueSidebar to use CSS variable-based theme tokens.
2026-05-18 20:44:52 +08:00
ZhenYi
86ab2d2f85 refactor(ui): update settings pages for new theme system
Update all settings pages (AccessKeys, Appearance, Billing, Email,
MyAccount, Notifications, Password, PushSettings, SettingsLayout,
SshKeys) to use CSS variable-based theme tokens.
2026-05-18 20:44:37 +08:00
ZhenYi
16739d3cf8 refactor(ui): update me/profile pages for new theme system
Update MeLayout, MePage, and all me/components (ActivityTimeline,
NotificationList, ProfileHeader, ProjectList, RepoList) to use
CSS variable-based theme tokens and improved layout.
2026-05-18 20:44:29 +08:00
ZhenYi
cab064f83f refactor(ui): update chat page components for new theme system
Update all Chat*.tsx components to use CSS variable-based theme
tokens, improve layout and styling consistency across conversation
list, header, message bubbles, input, message list, model selector,
and slash command menu.
2026-05-18 20:43:58 +08:00
ZhenYi
e3a79166c2 refactor(ui): update App routing structure and main entry point
Reorganize lazy-loaded route components and update import formatting.
Update main.tsx entry point to align with new theme system.
2026-05-18 20:43:49 +08:00
ZhenYi
f77955074e feat(ui): redesign theme system with CSS variable architecture
Refactor index.css to use CSS custom properties for all theme tokens.
Add theme-vars.ts and color.ts utility modules. Update theme-presets
to use new variable structure. Overhaul ThemeCustomization and
ThemePresetSelector components.
2026-05-18 20:43:42 +08:00
ZhenYi
3df7ae78c9 feat(models): update AI subagent session with role fields and room streaming
Add role_name and parent_call_id fields to subagent session model.
Update room struct and AI streaming service to align with new
sub-agent orchestration.
2026-05-18 20:43:34 +08:00
ZhenYi
4034e98dfb refactor(service): multi-root skill scanner and chat/join_request updates
Skill scanner now walks .claude/skills and .codex/skills directories
separately, adds relative_path/system fields to DiscoveredSkill, and
supports root-level SKILL.md. Update chat context and join request
handling.
2026-05-18 20:43:27 +08:00
ZhenYi
3faaff6220 refactor(git): expand hook sync with skill scanning and multi-root discovery
Update sync module to support .claude/skills and .codex/skills roots,
add system/source tracking to discovered skills, and refactor
migration path for the new SQL-file based migrator.
2026-05-18 20:43:16 +08:00
ZhenYi
1d48cdc973 feat(fctool): add git LFS, merge analysis, ref listing, status tools and Bing search
New git subcommands: lfs (summary/scan_tree), merge_analysis,
ref_list/ref_info, and git_status. New project tool: bing_search.
Update repo_analysis with expanded field coverage and curl tool.
2026-05-18 20:43:08 +08:00
ZhenYi
8d144ac139 feat(agent): add architect, debugger, implementer, tester, security sub-agent roles
Extend delegation system with 5 new specialized roles alongside
researcher/analyst/reviewer. Each role has curated tool access.
Refactor profile lookup to use profile_for_role_name and update
compact/summarizer and tool context accordingly.
2026-05-18 20:42:57 +08:00
ZhenYi
b413edccaf refactor(migrate): replace hand-written migrations with SQL-file macro system
Replace individual Rust migration modules with a define_sql_migrations
macro that reads up/down SQL files via include_str!. Consolidate all
legacy single-file SQL into per-table directories and add full schema
migration coverage for 90+ tables.
2026-05-18 20:42:47 +08:00
296 changed files with 11162 additions and 6908 deletions

View File

@ -15,6 +15,7 @@ fn shared_tools() -> Vec<String> {
"git_file_content".into(),
"git_blob_get".into(),
"git_blob_content".into(),
"git_status".into(),
]
}
@ -25,8 +26,10 @@ fn researcher_tools() -> Vec<String> {
"git_search_commits".into(),
"repo_search".into(),
"repo_doc_search".into(),
"project_bing_search".into(),
"project_arxiv_search".into(),
"project_curl".into(),
"curl_exec".into(),
// Index / overview
"repo_overview".into(),
"repo_readme".into(),
@ -45,6 +48,10 @@ fn researcher_tools() -> Vec<String> {
"git_graph".into(),
"git_commit_info".into(),
"repo_commit_log".into(),
"git_ref_list".into(),
"git_ref_info".into(),
"git_lfs_summary".into(),
"git_lfs_scan_tree".into(),
]
}
@ -65,6 +72,9 @@ fn analyst_tools() -> Vec<String> {
"git_branch_list".into(),
"git_branch_info".into(),
"git_branch_diff".into(),
"git_merge_analysis".into(),
"git_ref_list".into(),
"git_ref_info".into(),
// Project data
"project_list_issues".into(),
"project_list_repos".into(),
@ -81,6 +91,7 @@ fn reviewer_tools() -> Vec<String> {
// Merge status
"git_branches_merged".into(),
"git_branch_info".into(),
"git_merge_analysis".into(),
// Tracking
"project_list_issues".into(),
"project_update_issue".into(),
@ -90,6 +101,108 @@ fn reviewer_tools() -> Vec<String> {
]
}
/// Architect-specific tools (system design & dependency mapping).
fn architect_tools() -> Vec<String> {
vec![
// Repository structure
"repo_overview".into(),
"repo_readme".into(),
"repo_file_tree".into(),
"repo_languages".into(),
"repo_dependencies".into(),
"repo_test_discovery".into(),
// Change and branch context
"repo_diff_summary".into(),
"git_branch_list".into(),
"git_branch_info".into(),
"git_branch_diff".into(),
"git_merge_analysis".into(),
"git_ref_list".into(),
// Project context
"project_list_repos".into(),
"project_list_issues".into(),
]
}
/// Debugger-specific tools (root-cause analysis & history tracing).
fn debugger_tools() -> Vec<String> {
vec![
// Failure and change inspection
"git_show".into(),
"git_diff".into(),
"git_diff_stats".into(),
"git_blame".into(),
"git_log".into(),
"git_commit_info".into(),
"git_ref_info".into(),
// Structural clues
"repo_file_tree".into(),
"repo_dependencies".into(),
"repo_test_discovery".into(),
"repo_doc_search".into(),
// Issue context
"project_list_issues".into(),
]
}
/// Implementer-specific tools (implementation planning & code navigation).
fn implementer_tools() -> Vec<String> {
vec![
// Code and documentation context
"repo_overview".into(),
"repo_readme".into(),
"repo_file_tree".into(),
"repo_doc_index".into(),
"repo_doc_read".into(),
"repo_dependencies".into(),
"repo_test_discovery".into(),
// Current change context
"git_diff".into(),
"git_diff_stats".into(),
"git_show".into(),
"git_branch_info".into(),
// Project context
"project_list_issues".into(),
]
}
/// Tester-specific tools (coverage, regression, and validation planning).
fn tester_tools() -> Vec<String> {
vec![
// Test discovery and changed surface
"git_grep".into(),
"git_diff".into(),
"git_diff_stats".into(),
"git_show".into(),
"repo_file_tree".into(),
"repo_dependencies".into(),
"repo_test_discovery".into(),
// Project context
"project_list_issues".into(),
]
}
/// Security-specific tools (threat modeling & sensitive-code review).
fn security_tools() -> Vec<String> {
vec![
// Sensitive pattern discovery
"git_grep".into(),
"git_diff".into(),
"git_diff_stats".into(),
"git_blame".into(),
"git_log".into(),
// Dependency and surface review
"repo_file_tree".into(),
"repo_dependencies".into(),
"repo_doc_search".into(),
"git_lfs_summary".into(),
"git_lfs_pointer_info".into(),
"git_lfs_object_info".into(),
// Issue context
"project_list_issues".into(),
]
}
/// Supervisor-specific tools (delegation & synthesis).
fn supervisor_tools() -> Vec<String> {
vec![
@ -105,12 +218,31 @@ pub fn tools_for_role(role: &AgentRole) -> Vec<String> {
AgentRole::Researcher => tools.extend(researcher_tools()),
AgentRole::Analyst => tools.extend(analyst_tools()),
AgentRole::Reviewer => tools.extend(reviewer_tools()),
AgentRole::Architect => tools.extend(architect_tools()),
AgentRole::Debugger => tools.extend(debugger_tools()),
AgentRole::Implementer => tools.extend(implementer_tools()),
AgentRole::Tester => tools.extend(tester_tools()),
AgentRole::Security => tools.extend(security_tools()),
AgentRole::Supervisor => tools.extend(supervisor_tools()),
AgentRole::Default => {} // Default role gets only shared tools
}
tools
}
pub fn profile_for_role_name(role: &str) -> AgentExecutionProfile {
match role {
"researcher" => researcher_profile(),
"analyst" => analyst_profile(),
"reviewer" => reviewer_profile(),
"architect" => architect_profile(),
"debugger" => debugger_profile(),
"implementer" => implementer_profile(),
"tester" => tester_profile(),
"security" => security_profile(),
_ => researcher_profile(),
}
}
pub fn supervisor_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Supervisor,
@ -123,6 +255,11 @@ pub fn supervisor_profile() -> AgentExecutionProfile {
- **researcher**: Gathers concrete facts, evidence, and data from tools and context. Best for finding information, searching code, and discovering evidence.\n\
- **analyst**: Builds coherent explanations, highlights causal links, edge cases, and tradeoffs. Best for explaining findings and reasoning about implications.\n\
- **reviewer**: Stress-tests proposals, identifies contradictions, missing assumptions, regressions, and risks. Best for quality checks and risk assessment.\n\
- **architect**: Maps systems, boundaries, dependencies, and long-term design tradeoffs. Best for architecture decisions and refactor strategy.\n\
- **debugger**: Finds likely root causes, reproduction gaps, and suspect changes. Best for failures, regressions, and confusing behavior.\n\
- **implementer**: Turns requirements into concrete implementation steps, affected files, and integration concerns. Best for execution planning.\n\
- **tester**: Designs validation strategy, regression coverage, and edge-case test plans. Best for test planning and release confidence.\n\
- **security**: Reviews auth, data exposure, injection, dependency, and abuse risks. Best for threat modeling and sensitive changes.\n\
- Provide a clear, focused task description for each sub-agent.\n\
- You may call multiple sub-agents in sequence (call one, review its output, then decide to call another).\n\
- You may also call the same role twice with different tasks if needed.\n\
@ -130,7 +267,10 @@ pub fn supervisor_profile() -> AgentExecutionProfile {
## Decision Guide\n\
- Simple factual questions: call researcher only.\n\
- Questions requiring explanation: call researcher then analyst.\n\
- Design/architecture reviews: call researcher, analyst, then reviewer.\n\
- Design/architecture reviews: call researcher, architect, then reviewer.\n\
- Bug or regression diagnosis: call debugger, then tester if validation is needed.\n\
- Implementation requests: call implementer, then reviewer for risk checks.\n\
- Security-sensitive changes: call security, then reviewer.\n\
- If a sub-agent's output is insufficient, call another sub-agent for clarification.\n\
\n\
## Output Rules\n\
@ -201,8 +341,93 @@ pub fn reviewer_profile() -> AgentExecutionProfile {
}
}
pub fn architect_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Architect,
system_prompt: Some(
"You are the architect agent. Map system boundaries, dependencies, data flow, and design tradeoffs. Prefer practical architecture guidance tied to repository evidence. Call out migration risks and long-term maintainability concerns.".to_string(),
),
temperature: Some(0.2),
max_tokens: Some(2000),
top_p: Some(1.0),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
max_tool_depth: Some(5),
allowed_tools: Some(tools_for_role(&AgentRole::Architect)),
disable_orchestration: true,
}
}
pub fn debugger_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Debugger,
system_prompt: Some(
"You are the debugger agent. Identify likely root causes, suspect files or commits, missing reproduction details, and the shortest validation path. Separate evidence from hypotheses and rank hypotheses by plausibility.".to_string(),
),
temperature: Some(0.1),
max_tokens: Some(1800),
top_p: Some(1.0),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
max_tool_depth: Some(6),
allowed_tools: Some(tools_for_role(&AgentRole::Debugger)),
disable_orchestration: true,
}
}
pub fn implementer_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Implementer,
system_prompt: Some(
"You are the implementer agent. Convert requirements into a concrete execution plan: files to touch, sequencing, integration points, and risks. Keep recommendations actionable and avoid broad rewrites unless justified.".to_string(),
),
temperature: Some(0.15),
max_tokens: Some(1800),
top_p: Some(1.0),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
max_tool_depth: Some(5),
allowed_tools: Some(tools_for_role(&AgentRole::Implementer)),
disable_orchestration: true,
}
}
pub fn tester_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Tester,
system_prompt: Some(
"You are the tester agent. Design high-signal validation: unit, integration, regression, and edge-case coverage. Identify what must be tested, what can be skipped, and the fastest commands or checks to build confidence.".to_string(),
),
temperature: Some(0.1),
max_tokens: Some(1600),
top_p: Some(1.0),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
max_tool_depth: Some(4),
allowed_tools: Some(tools_for_role(&AgentRole::Tester)),
disable_orchestration: true,
}
}
pub fn security_profile() -> AgentExecutionProfile {
AgentExecutionProfile {
role: AgentRole::Security,
system_prompt: Some(
"You are the security agent. Review authentication, authorization, data exposure, injection, dependency, secret-handling, and abuse-case risks. Prioritize exploitable issues and concrete mitigations over generic advice.".to_string(),
),
temperature: Some(0.1),
max_tokens: Some(1800),
top_p: Some(1.0),
frequency_penalty: Some(0.0),
presence_penalty: Some(0.0),
max_tool_depth: Some(5),
allowed_tools: Some(tools_for_role(&AgentRole::Security)),
disable_orchestration: true,
}
}
/// Whether to enable multi-agent delegation for this request.
/// Simplified from keyword-based gating: delegation is enabled when tools are available.
pub fn should_enable_delegation(_input: &str, tools_available: bool) -> bool {
tools_available
}
}

View File

@ -2,7 +2,7 @@ use std::pin::Pin;
use std::sync::Arc;
use uuid::Uuid;
use super::agent_profile::{analyst_profile, researcher_profile, reviewer_profile};
use super::agent_profile::profile_for_role_name;
use crate::client::AiClientConfig;
use crate::client::types::{ChatRequestMessage, ToolCall};
use crate::client::{StreamChunk, StreamChunkType, StreamedToolCall, call_stream};
@ -629,13 +629,13 @@ pub async fn execute_chat_stream(
"type": "function",
"function": {
"name": "call_sub_agent",
"description": "Delegate a task to a specialist sub-agent and receive its output.\nAvailable roles:\n- researcher: Gathers facts, evidence, and data. Best for finding information and searching code.\n- analyst: Builds explanations, highlights causal links and tradeoffs. Best for reasoning about implications.\n- reviewer: Stress-tests proposals, identifies risks and contradictions. Best for quality checks.\nProvide a clear, focused task description so the sub-agent knows exactly what to investigate.",
"description": "Delegate a task to a specialist sub-agent and receive its output.\nAvailable roles:\n- researcher: Gathers facts, evidence, and data. Best for finding information and searching code.\n- analyst: Builds explanations, highlights causal links and tradeoffs. Best for reasoning about implications.\n- reviewer: Stress-tests proposals, identifies risks and contradictions. Best for quality checks.\n- architect: Maps systems, dependencies, boundaries, and design tradeoffs. Best for architecture decisions.\n- debugger: Finds root causes, suspect changes, and validation paths. Best for bugs and regressions.\n- implementer: Converts requirements into concrete implementation steps. Best for execution planning.\n- tester: Designs validation and regression coverage. Best for test strategy.\n- security: Reviews auth, data exposure, injection, dependency, and abuse risks. Best for sensitive changes.\nProvide a clear, focused task description so the sub-agent knows exactly what to investigate.",
"parameters": {
"type": "object",
"properties": {
"role": {
"type": "string",
"description": "The sub-agent role to delegate to: researcher, analyst, or reviewer."
"description": "The sub-agent role to delegate to: researcher, analyst, reviewer, architect, debugger, implementer, tester, or security."
},
"task": {
"type": "string",
@ -805,11 +805,7 @@ pub async fn execute_chat_stream(
.unwrap_or("researcher");
let task = args.get("task").and_then(|v| v.as_str()).unwrap_or("");
let profile = match role {
"analyst" => analyst_profile(),
"reviewer" => reviewer_profile(),
_ => researcher_profile(),
};
let profile = profile_for_role_name(role);
// Generate children_id BEFORE starting sub-agent execution
let sub_agent_id = format!("sub-agent-{}", Uuid::now_v7());

View File

@ -87,6 +87,11 @@ pub enum AgentRole {
Researcher,
Analyst,
Reviewer,
Architect,
Debugger,
Implementer,
Tester,
Security,
}
#[derive(Debug, Clone, Default)]

View File

@ -1,9 +1,6 @@
use std::collections::HashMap;
use super::agent_profile::{
analyst_profile, researcher_profile, reviewer_profile, should_enable_delegation,
supervisor_profile,
};
use super::agent_profile::{profile_for_role_name, should_enable_delegation, supervisor_profile};
use super::message_builder::MessageBuilder;
use super::nonstreaming_execution::execute_process;
use super::service::{ProcessResult, StreamResult};
@ -67,9 +64,8 @@ pub async fn execute_orchestrated_process(
supervisor_request.frequency_penalty = profile
.frequency_penalty
.unwrap_or(request.frequency_penalty);
supervisor_request.presence_penalty = profile
.presence_penalty
.unwrap_or(request.presence_penalty);
supervisor_request.presence_penalty =
profile.presence_penalty.unwrap_or(request.presence_penalty);
execute_process(
supervisor_request,
@ -138,9 +134,8 @@ pub async fn execute_orchestrated_stream(
supervisor_request.frequency_penalty = profile
.frequency_penalty
.unwrap_or(request.frequency_penalty);
supervisor_request.presence_penalty = profile
.presence_penalty
.unwrap_or(request.presence_penalty);
supervisor_request.presence_penalty =
profile.presence_penalty.unwrap_or(request.presence_penalty);
super::streaming_execution::execute_process_stream(
supervisor_request,
@ -175,6 +170,11 @@ fn register_call_sub_agent_tool(
- researcher: Gathers facts, evidence, and data. Best for finding information and searching code.\n\
- analyst: Builds explanations, highlights causal links and tradeoffs. Best for reasoning about implications.\n\
- reviewer: Stress-tests proposals, identifies risks and contradictions. Best for quality checks.\n\
- architect: Maps systems, dependencies, boundaries, and design tradeoffs. Best for architecture decisions.\n\
- debugger: Finds root causes, suspect changes, and validation paths. Best for bugs and regressions.\n\
- implementer: Converts requirements into concrete implementation steps. Best for execution planning.\n\
- tester: Designs validation and regression coverage. Best for test strategy.\n\
- security: Reviews auth, data exposure, injection, dependency, and abuse risks. Best for sensitive changes.\n\
Provide a clear, focused task description so the sub-agent knows exactly what to investigate.",
)
.parameters(ToolSchema {
@ -187,7 +187,7 @@ fn register_call_sub_agent_tool(
name: "role".into(),
param_type: "string".into(),
description: Some(
"The sub-agent role to delegate to: researcher, analyst, or reviewer.".into(),
"The sub-agent role to delegate to: researcher, analyst, reviewer, architect, debugger, implementer, tester, or security.".into(),
),
required: true,
properties: None,
@ -224,12 +224,7 @@ fn register_call_sub_agent_tool(
.unwrap_or("")
.to_owned();
let profile = match role.as_str() {
"researcher" => researcher_profile(),
"analyst" => analyst_profile(),
"reviewer" => reviewer_profile(),
_ => researcher_profile(),
};
let profile = profile_for_role_name(role.as_str());
let mut sub_request = captured_request.clone();
sub_request.input = format!(
@ -288,9 +283,12 @@ fn filter_tools_for_sub_agent(
let Some(tools) = original_tools else {
return Vec::new();
};
let allowed = allowed_tools
.as_ref()
.map(|list| list.iter().filter(|n| *n != "call_sub_agent").cloned().collect::<Vec<String>>());
let allowed = allowed_tools.as_ref().map(|list| {
list.iter()
.filter(|n| *n != "call_sub_agent")
.cloned()
.collect::<Vec<String>>()
});
match allowed {
Some(allowed_list) if !allowed_list.is_empty() => tools
@ -308,8 +306,7 @@ fn filter_tools_for_sub_agent(
_ => tools
.iter()
.filter(|tool| {
tool
.get("function")
tool.get("function")
.and_then(|f| f.get("name"))
.and_then(|v| v.as_str())
.is_some_and(|name| name != "call_sub_agent")
@ -317,4 +314,4 @@ fn filter_tools_for_sub_agent(
.cloned()
.collect(),
}
}
}

View File

@ -165,10 +165,7 @@ impl ChatService {
request: AiRequest,
room_tools: ToolRegistry,
) -> Result<ProcessResult> {
let mut merged = self
.tool_registry
.clone()
.unwrap_or_default();
let mut merged = self.tool_registry.clone().unwrap_or_default();
merged.merge(room_tools);
super::nonstreaming_execution::execute_process(
@ -191,10 +188,7 @@ impl ChatService {
on_chunk: StreamCallback,
room_tools: ToolRegistry,
) -> Result<StreamResult> {
let mut merged = self
.tool_registry
.clone()
.unwrap_or_default();
let mut merged = self.tool_registry.clone().unwrap_or_default();
merged.merge(room_tools);
super::streaming_execution::execute_process_stream(

View File

@ -198,9 +198,7 @@ impl super::CompactService {
.unwrap_or_else(|_| estimate_input.len() / 4);
let retain_count = Self::resolve_retain_count(config, estimated_tokens);
if estimated_tokens >= config.token_threshold
&& messages.len() > retain_count
{
if estimated_tokens >= config.token_threshold && messages.len() > retain_count {
let split_index = messages.len().saturating_sub(retain_count);
let (to_summarize, retained_messages) = messages.split_at(split_index);
let from_seq = to_summarize

View File

@ -2,11 +2,11 @@ use models::rooms::room_message::Model as RoomMessageModel;
use models::users::user::{Column as UserCol, Entity as User};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use crate::AgentError;
use crate::client::call_with_params;
use crate::client::types::ChatRequestMessage;
use crate::compact::types::{CompactConfig, MessageSummary};
use crate::tokent::{TokenUsage, count_message_text};
use crate::AgentError;
const DEFAULT_MODEL_CONTEXT_LIMIT: usize = 128_000;
const MODEL_INPUT_RATIO_NUMERATOR: usize = 85;
@ -193,7 +193,10 @@ impl super::CompactService {
)
.await?;
Ok((final_summary, if has_usage { Some(total_usage) } else { None }))
Ok((
final_summary,
if has_usage { Some(total_usage) } else { None },
))
}
async fn merge_summary_rounds(
@ -230,11 +233,7 @@ impl super::CompactService {
for pair_text in fitted_pairs {
let prompt = self.build_prompt(kind, true, &pair_text, current_budget);
let (summary, usage) = self
.invoke_summary_prompt(
&prompt,
current_budget,
Self::temperature_for(kind),
)
.invoke_summary_prompt(&prompt, current_budget, Self::temperature_for(kind))
.await?;
Self::accumulate_usage(total_usage, has_usage, usage);
next_round.push(summary);
@ -474,18 +473,16 @@ impl super::CompactService {
}
fn safe_model_input_budget_from_limit(model_context_limit: Option<usize>) -> usize {
let context_limit = model_context_limit.unwrap_or(DEFAULT_MODEL_CONTEXT_LIMIT).max(1);
let context_limit = model_context_limit
.unwrap_or(DEFAULT_MODEL_CONTEXT_LIMIT)
.max(1);
context_limit
.saturating_mul(MODEL_INPUT_RATIO_NUMERATOR)
.saturating_div(MODEL_INPUT_RATIO_DENOMINATOR)
.max(1)
}
fn accumulate_usage(
total: &mut TokenUsage,
has_usage: &mut bool,
usage: Option<TokenUsage>,
) {
fn accumulate_usage(total: &mut TokenUsage, has_usage: &mut bool, usage: Option<TokenUsage>) {
if let Some(usage) = usage {
total.input_tokens += usage.input_tokens;
total.output_tokens += usage.output_tokens;
@ -500,7 +497,10 @@ mod tests {
#[test]
fn room_summary_uses_eighty_five_percent_input_budget() {
assert_eq!(CompactService::safe_model_input_budget_from_limit(Some(1000)), 850);
assert_eq!(
CompactService::safe_model_input_budget_from_limit(Some(1000)),
850
);
}
#[test]

View File

@ -19,8 +19,8 @@ pub use billing::{
initialize_user_billing, persist_billing_error, record_ai_usage, record_user_ai_usage,
};
pub use chat::{
AgentExecutionProfile, AgentRole, AiContextSenderType, AiRequest, AiStreamChunk,
ChatService, Mention, RoomMessageContext, StreamCallback,
AgentExecutionProfile, AgentRole, AiContextSenderType, AiRequest, AiStreamChunk, ChatService,
Mention, RoomMessageContext, StreamCallback,
};
pub use client::types::ChatRequestMessage;
pub use client::{AiCallResponse, AiClientConfig, call_with_params, call_with_retry};

View File

@ -177,12 +177,7 @@ impl ToolContext {
return Err(existing);
}
match current.compare_exchange(
existing,
next,
Ordering::AcqRel,
Ordering::Relaxed,
) {
match current.compare_exchange(existing, next, Ordering::AcqRel, Ordering::Relaxed) {
Ok(_) => return Ok(()),
Err(_) => continue,
}

View File

@ -0,0 +1,262 @@
//! Git LFS query tools.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use git::lfs::types::LfsOid;
use std::collections::HashMap;
async fn git_lfs_summary_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let objects = domain.lfs_object_list().map_err(|e| e.to_string())?;
let cache_size = domain.lfs_cache_size().map_err(|e| e.to_string())?;
let attributes = domain.lfs_gitattributes_list().map_err(|e| e.to_string())?;
let config = domain.lfs_config().map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"object_count": objects.len(),
"cache_size_bytes": cache_size,
"objects": objects.into_iter().map(|oid| oid.to_string()).collect::<Vec<_>>(),
"gitattributes": attributes,
"config": {
"endpoint": config.endpoint,
"has_access_token": config.access_token.as_ref().is_some_and(|s| !s.is_empty()),
},
}))
}
async fn git_lfs_scan_tree_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
let recursive = p.get("recursive").and_then(|v| v.as_bool()).unwrap_or(true);
let domain = ctx.open_repo(project_name, repo_name).await?;
let commit_oid = resolve_rev(&domain, rev)?;
let commit = domain.commit_get(&commit_oid).map_err(|e| e.to_string())?;
let entries = domain
.lfs_scan_tree(&commit.tree_id, recursive)
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"rev": rev,
"commit_oid": commit_oid.to_string(),
"count": entries.len(),
"entries": entries.into_iter().map(|entry| {
serde_json::json!({
"path": entry.path,
"oid": entry.pointer.oid.to_string(),
"size": entry.pointer.size,
"cached": domain.lfs_object_cached(&entry.pointer.oid),
"extra": entry.pointer.extra,
})
}).collect::<Vec<_>>(),
}))
}
async fn git_lfs_pointer_info_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let rev = p.get("rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
let blob_oid = p.get("blob_oid").and_then(|v| v.as_str());
let path = p.get("path").and_then(|v| v.as_str());
let domain = ctx.open_repo(project_name, repo_name).await?;
let blob_oid = if let Some(blob_oid) = blob_oid {
git::commit::types::CommitOid::new(blob_oid)
} else if let Some(path) = path {
let commit_oid = resolve_rev(&domain, rev)?;
domain
.tree_entry_by_path_from_commit(&commit_oid, path)
.map_err(|e| e.to_string())?
.oid
} else {
return Err("either blob_oid or path is required".into());
};
let pointer = domain
.lfs_pointer_from_blob(&blob_oid)
.map_err(|e| e.to_string())?;
Ok(match pointer {
Some(pointer) => serde_json::json!({
"is_lfs_pointer": true,
"blob_oid": blob_oid.to_string(),
"pointer": {
"version": pointer.version,
"oid": pointer.oid.to_string(),
"size": pointer.size,
"cached": domain.lfs_object_cached(&pointer.oid),
"object_path": domain.lfs_object_path(&pointer.oid).ok().map(|p| p.to_string_lossy().to_string()),
"extra": pointer.extra,
}
}),
None => serde_json::json!({
"is_lfs_pointer": false,
"blob_oid": blob_oid.to_string(),
"pointer": null,
}),
})
}
async fn git_lfs_object_info_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let oid = LfsOid::new(required_str(&p, "oid")?);
let domain = ctx.open_repo(project_name, repo_name).await?;
if !oid.is_valid() {
return Err(format!("invalid LFS oid: {}", oid));
}
let path = domain.lfs_object_path(&oid).map_err(|e| e.to_string())?;
let cached = domain.lfs_object_cached(&oid);
let size = if cached {
std::fs::metadata(&path).ok().map(|m| m.len())
} else {
None
};
Ok(serde_json::json!({
"oid": oid.to_string(),
"cached": cached,
"path": path.to_string_lossy().to_string(),
"size_bytes": size,
}))
}
fn resolve_rev(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(git::commit::types::CommitOid::new(rev));
}
if let Ok(Some(oid)) = domain.ref_target(rev) {
return Ok(oid);
}
domain
.commit_get_prefix(rev)
.map(|m| m.oid)
.map_err(|e| e.to_string())
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
registry.register(
ToolDefinition::new("git_lfs_summary")
.description("Summarize Git LFS state for a repository: local LFS objects, cache size, .gitattributes LFS patterns, and sanitized LFS config.")
.parameters(base_schema(vec![])),
handler(git_lfs_summary_exec),
);
registry.register(
ToolDefinition::new("git_lfs_scan_tree")
.description("Scan a revision tree for Git LFS pointer files. Returns path, LFS object OID, declared size, and local cache status.")
.parameters(base_schema(vec![
param("rev", "string", "Revision to scan. Default HEAD.", false),
param("recursive", "boolean", "Scan recursively. Default true.", false),
])),
handler(git_lfs_scan_tree_exec),
);
registry.register(
ToolDefinition::new("git_lfs_pointer_info")
.description("Inspect whether a blob or file path is a Git LFS pointer. Provide either blob_oid or path; path is resolved at rev, default HEAD.")
.parameters(base_schema(vec![
param("blob_oid", "string", "Blob object ID to inspect.", false),
param("path", "string", "File path to inspect at rev.", false),
param("rev", "string", "Revision used when path is provided. Default HEAD.", false),
])),
handler(git_lfs_pointer_info_exec),
);
registry.register(
ToolDefinition::new("git_lfs_object_info")
.description("Inspect one local Git LFS object by SHA-256 OID and report cache path and size if present.")
.parameters(base_schema(vec![param(
"oid",
"string",
"64-character Git LFS object SHA-256 OID.",
true,
)])),
handler(git_lfs_object_info_exec),
);
}
fn handler<F, Fut>(f: F) -> ToolHandler
where
F: Fn(GitToolCtx, serde_json::Value) -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = Result<serde_json::Value, String>> + Send + 'static,
{
ToolHandler::new(move |ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
let fut = f(gctx, args);
Box::pin(async move { fut.await.map_err(agent::ToolError::ExecutionError) })
})
}
fn base_schema(extra: Vec<(String, ToolParam)>) -> ToolSchema {
let mut properties = HashMap::from([
param("project_name", "string", "Project name (slug)", true),
param("repo_name", "string", "Repository name", true),
]);
let mut required = vec!["project_name".into(), "repo_name".into()];
required.extend(
extra
.iter()
.filter(|(_, param)| param.required)
.map(|(name, _)| name.clone()),
);
properties.extend(extra);
ToolSchema {
schema_type: "object".into(),
properties: Some(properties),
required: Some(required),
}
}
fn parse_args(
args: serde_json::Value,
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
serde_json::from_value(args).map_err(|e| e.to_string())
}
fn required_str<'a>(
p: &'a serde_json::Map<String, serde_json::Value>,
name: &str,
) -> Result<&'a str, String> {
p.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| format!("missing {}", name))
}
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
(
name.into(),
ToolParam {
name: name.into(),
param_type: param_type.into(),
description: Some(description.into()),
required,
properties: None,
items: None,
},
)
}

View File

@ -0,0 +1,109 @@
//! Git merge analysis tools.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
async fn git_merge_analysis_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let target = required_str(&p, "target")?;
let base_ref = p.get("base_ref").and_then(|v| v.as_str()).unwrap_or("HEAD");
let domain = ctx.open_repo(project_name, repo_name).await?;
let target_oid = resolve_rev(&domain, target)?;
let (analysis, preference) = if base_ref == "HEAD" {
domain
.merge_analysis(&target_oid)
.map_err(|e| e.to_string())?
} else {
domain
.merge_analysis_for_ref(base_ref, &target_oid)
.map_err(|e| e.to_string())?
};
let base_oid = domain.ref_target(base_ref).ok().flatten();
let merge_base = base_oid
.as_ref()
.and_then(|base| domain.merge_base(base, &target_oid).ok())
.map(|oid| oid.to_string());
Ok(serde_json::json!({
"base_ref": base_ref,
"base_oid": base_oid.map(|oid| oid.to_string()),
"target": target,
"target_oid": target_oid.to_string(),
"merge_base": merge_base,
"analysis": analysis,
"preference": preference,
}))
}
fn resolve_rev(
domain: &git::GitDomain,
rev: &str,
) -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(git::commit::types::CommitOid::new(rev));
}
if let Ok(Some(oid)) = domain.ref_target(rev) {
return Ok(oid);
}
domain
.commit_get_prefix(rev)
.map(|m| m.oid)
.map_err(|e| e.to_string())
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
registry.register(
ToolDefinition::new("git_merge_analysis")
.description("Analyze whether a target commit/ref can be merged into a base ref. Returns up-to-date, fast-forward, normal merge, unborn, merge preference, and merge-base information. This is read-only.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param("project_name", "string", "Project name (slug)", true),
param("repo_name", "string", "Repository name", true),
param("target", "string", "Target revision/ref to merge, e.g. refs/heads/feature, feature, or a commit SHA.", true),
param("base_ref", "string", "Base ref to merge into. Default HEAD.", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "target".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_merge_analysis_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
}
fn required_str<'a>(
p: &'a serde_json::Map<String, serde_json::Value>,
name: &str,
) -> Result<&'a str, String> {
p.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| format!("missing {}", name))
}
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
(
name.into(),
ToolParam {
name: name.into(),
param_type: param_type.into(),
description: Some(description.into()),
required,
properties: None,
items: None,
},
)
}

View File

@ -9,8 +9,12 @@ pub mod commit;
pub mod ctx;
pub mod diff;
pub mod kb;
pub mod lfs;
pub mod merge;
pub mod reference;
pub mod repo_analysis;
pub mod repo_util;
pub mod status;
pub mod tag;
pub mod tree;
pub mod types;
@ -18,11 +22,15 @@ pub mod types;
/// Batch-register all git tools into a ToolRegistry.
pub fn register_all(registry: &mut agent::ToolRegistry) {
commit::register_git_tools(registry);
status::register_git_tools(registry);
branch::register_git_tools(registry);
reference::register_git_tools(registry);
merge::register_git_tools(registry);
diff::register_git_tools(registry);
blob::register_git_tools(registry);
tree::register_git_tools(registry);
tag::register_git_tools(registry);
lfs::register_git_tools(registry);
repo_analysis::register_git_tools(registry);
kb::register_git_tools(registry);
repo_util::register_git_tools(registry);

View File

@ -0,0 +1,137 @@
//! Git reference query tools.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
async fn git_ref_list_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let pattern = p.get("pattern").and_then(|v| v.as_str());
let domain = ctx.open_repo(project_name, repo_name).await?;
let refs = domain.ref_list(pattern).map_err(|e| e.to_string())?;
Ok(serde_json::json!({
"count": refs.len(),
"refs": refs.into_iter().map(ref_json).collect::<Vec<_>>(),
}))
}
async fn git_ref_info_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p = parse_args(args)?;
let project_name = required_str(&p, "project_name")?;
let repo_name = required_str(&p, "repo_name")?;
let name = required_str(&p, "name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let info = domain.ref_get(name).map_err(|e| e.to_string())?;
Ok(ref_json(info))
}
fn ref_json(info: git::reference::types::RefInfo) -> serde_json::Value {
serde_json::json!({
"name": info.name,
"oid": info.oid.map(|oid| oid.to_string()),
"target": info.target.map(|oid| oid.to_string()),
"is_symbolic": info.is_symbolic,
"is_branch": info.is_branch,
"is_remote": info.is_remote,
"is_tag": info.is_tag,
"is_note": info.is_note,
})
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
registry.register(
ToolDefinition::new("git_ref_list")
.description("List Git references such as branches, remote-tracking refs, tags, and notes. Optional pattern supports exact names plus refs/heads/* or refs/tags/** style prefix matching.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param("project_name", "string", "Project name (slug)", true),
param("repo_name", "string", "Repository name", true),
param("pattern", "string", "Optional ref pattern, e.g. refs/heads/*, refs/tags/**, or refs/heads/main.", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_ref_list_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
registry.register(
ToolDefinition::new("git_ref_info")
.description(
"Get one Git reference by full name, including peeled commit OID and target OID.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param("project_name", "string", "Project name (slug)", true),
param("repo_name", "string", "Repository name", true),
param(
"name",
"string",
"Full ref name, e.g. refs/heads/main or refs/tags/v1.0.0.",
true,
),
])),
required: Some(vec![
"project_name".into(),
"repo_name".into(),
"name".into(),
]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_ref_info_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
}
fn parse_args(
args: serde_json::Value,
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
serde_json::from_value(args).map_err(|e| e.to_string())
}
fn required_str<'a>(
p: &'a serde_json::Map<String, serde_json::Value>,
name: &str,
) -> Result<&'a str, String> {
p.get(name)
.and_then(|v| v.as_str())
.ok_or_else(|| format!("missing {}", name))
}
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
(
name.into(),
ToolParam {
name: name.into(),
param_type: param_type.into(),
description: Some(description.into()),
required,
properties: None,
items: None,
},
)
}

View File

@ -27,6 +27,23 @@ const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
("Makefile", "make"),
];
const TEST_FILE_MARKERS: &[&str] = &[
"_test.", ".test.", ".spec.", "_spec.", "test.", "tests.", "test_", ".feature",
];
const TEST_DIR_MARKERS: &[&str] = &[
"test",
"tests",
"__tests__",
"spec",
"specs",
"e2e",
"integration",
"unit",
"cypress",
"playwright",
];
/// Language detection by file extension (lowercase).
fn ext_to_language(ext: &str) -> Option<&'static str> {
match ext {
@ -648,6 +665,271 @@ async fn repo_dependencies_exec(
// ── Registration ───────────────────────────────────────────────────────────────
/// Tool: repo_test_discovery - discover likely tests and test commands.
async fn repo_test_discovery_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let tree = head_tree(&domain)?;
let repo = domain.repo();
let mut test_files = Vec::new();
let mut manifests = Vec::new();
let mut frameworks: HashMap<String, u64> = HashMap::new();
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
let lower_path = entry_path.to_lowercase();
let lower_name = name.to_lowercase();
if is_test_file(&lower_path, &lower_name) && test_files.len() < max_files {
test_files.push(serde_json::json!({
"path": entry_path,
"kind": test_kind(&lower_path),
}));
}
if is_test_manifest(name) {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
for framework in detect_test_frameworks(name, &content) {
*frameworks.entry(framework).or_insert(0) += 1;
}
manifests.push(serde_json::json!({ "path": entry_path, "name": name }));
}
}
}
_ => {}
}
}
}
let mut framework_list: Vec<_> = frameworks
.into_iter()
.map(|(name, evidence_count)| {
serde_json::json!({ "name": name, "evidence_count": evidence_count })
})
.collect();
framework_list.sort_by(|a, b| {
b["evidence_count"]
.as_u64()
.unwrap_or(0)
.cmp(&a["evidence_count"].as_u64().unwrap_or(0))
});
let commands = infer_test_commands(&framework_list, &manifests);
Ok(serde_json::json!({
"test_file_count_returned": test_files.len(),
"test_files": test_files,
"test_manifests": manifests,
"frameworks": framework_list,
"suggested_commands": commands,
}))
}
fn is_test_file(lower_path: &str, lower_name: &str) -> bool {
TEST_FILE_MARKERS
.iter()
.any(|marker| lower_name.contains(marker))
|| lower_path
.split('/')
.any(|part| TEST_DIR_MARKERS.iter().any(|marker| part == *marker))
}
fn test_kind(lower_path: &str) -> &'static str {
if lower_path.contains("/e2e/")
|| lower_path.contains("/cypress/")
|| lower_path.contains("/playwright/")
{
"e2e"
} else if lower_path.contains("/integration/") {
"integration"
} else if lower_path.contains("/unit/") {
"unit"
} else {
"test"
}
}
fn is_test_manifest(name: &str) -> bool {
matches!(
name,
"Cargo.toml"
| "package.json"
| "go.mod"
| "pyproject.toml"
| "requirements.txt"
| "pom.xml"
| "build.gradle"
| "build.gradle.kts"
| "pytest.ini"
| "tox.ini"
| "vitest.config.ts"
| "vitest.config.js"
| "jest.config.js"
| "jest.config.ts"
| "playwright.config.ts"
| "playwright.config.js"
| "cypress.config.ts"
| "cypress.config.js"
)
}
fn detect_test_frameworks(name: &str, content: &str) -> Vec<String> {
let lower = content.to_lowercase();
let mut found = Vec::new();
let mut add = |framework: &str| {
if !found.iter().any(|v| v == framework) {
found.push(framework.to_string());
}
};
match name {
"Cargo.toml" => {
if lower.contains("[dev-dependencies]")
|| lower.contains("tokio-test")
|| lower.contains("rstest")
{
add("cargo test");
}
}
"package.json" => {
if lower.contains("\"test\"") {
add("npm test");
}
if lower.contains("vitest") {
add("vitest");
}
if lower.contains("jest") {
add("jest");
}
if lower.contains("playwright") {
add("playwright");
}
if lower.contains("cypress") {
add("cypress");
}
}
"go.mod" => add("go test"),
"pyproject.toml" | "requirements.txt" | "pytest.ini" | "tox.ini" => {
if lower.contains("pytest") {
add("pytest");
}
if lower.contains("tox") {
add("tox");
}
}
"pom.xml" => add("maven test"),
"build.gradle" | "build.gradle.kts" => add("gradle test"),
_ => {
if name.starts_with("vitest.config") {
add("vitest");
} else if name.starts_with("jest.config") {
add("jest");
} else if name.starts_with("playwright.config") {
add("playwright");
} else if name.starts_with("cypress.config") {
add("cypress");
}
}
}
found
}
fn infer_test_commands(
frameworks: &[serde_json::Value],
manifests: &[serde_json::Value],
) -> Vec<serde_json::Value> {
let names: Vec<String> = frameworks
.iter()
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
.collect();
let manifest_names: Vec<String> = manifests
.iter()
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string))
.collect();
let mut commands = Vec::new();
let mut add = |command: &str, reason: &str| {
if !commands
.iter()
.any(|v: &serde_json::Value| v.get("command").and_then(|c| c.as_str()) == Some(command))
{
commands.push(serde_json::json!({ "command": command, "reason": reason }));
}
};
if names.iter().any(|n| n == "cargo test") || manifest_names.iter().any(|n| n == "Cargo.toml") {
add("cargo test", "Rust Cargo manifest detected");
}
if names.iter().any(|n| n == "npm test") || manifest_names.iter().any(|n| n == "package.json") {
add("npm test", "Node package.json detected");
}
if names.iter().any(|n| n == "vitest") {
add("npx vitest run", "Vitest detected");
}
if names.iter().any(|n| n == "jest") {
add("npx jest", "Jest detected");
}
if names.iter().any(|n| n == "playwright") {
add("npx playwright test", "Playwright detected");
}
if names.iter().any(|n| n == "cypress") {
add("npx cypress run", "Cypress detected");
}
if names.iter().any(|n| n == "go test") || manifest_names.iter().any(|n| n == "go.mod") {
add("go test ./...", "Go module detected");
}
if names.iter().any(|n| n == "pytest") {
add("pytest", "Pytest detected");
}
if names.iter().any(|n| n == "tox") {
add("tox", "Tox detected");
}
if names.iter().any(|n| n == "maven test") || manifest_names.iter().any(|n| n == "pom.xml") {
add("mvn test", "Maven project detected");
}
if names.iter().any(|n| n == "gradle test")
|| manifest_names
.iter()
.any(|n| n == "build.gradle" || n == "build.gradle.kts")
{
add("./gradlew test", "Gradle project detected");
}
commands
}
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
@ -746,4 +1028,27 @@ pub fn register_git_tools(registry: &mut ToolRegistry) {
})
}),
);
// repo_test_discovery
registry.register(
ToolDefinition::new("repo_test_discovery")
.description("Discover likely test files, test frameworks, and suggested test commands from repository manifests and file layout. Useful before changing code or planning validation.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("max_files", "integer", "Maximum test file entries to return (default: 200)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_test_discovery_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -0,0 +1,199 @@
//! Git status tools.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
async fn git_status_exec(
ctx: GitToolCtx,
args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> =
serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p
.get("project_name")
.and_then(|v| v.as_str())
.ok_or("missing project_name")?;
let repo_name = p
.get("repo_name")
.and_then(|v| v.as_str())
.ok_or("missing repo_name")?;
let include_ignored = p
.get("include_ignored")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let is_bare = repo.is_bare();
let head = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(str::to_string));
if is_bare {
return Ok(serde_json::json!({
"is_bare": true,
"head": head,
"is_dirty": false,
"files": [],
"summary": {
"total": 0,
"index": 0,
"worktree": 0,
"untracked": 0,
"ignored": 0,
"conflicted": 0,
}
}));
}
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true)
.renames_head_to_index(true)
.renames_index_to_workdir(true)
.recurse_untracked_dirs(true);
if include_ignored {
opts.include_ignored(true);
}
let statuses = repo
.statuses(Some(&mut opts))
.map_err(|e| format!("git status failed: {}", e))?;
let mut files = Vec::new();
let mut index = 0usize;
let mut worktree = 0usize;
let mut untracked = 0usize;
let mut ignored = 0usize;
let mut conflicted = 0usize;
for entry in statuses.iter() {
let status = entry.status();
let path = entry
.head_to_index()
.and_then(|d| d.new_file().path())
.or_else(|| entry.index_to_workdir().and_then(|d| d.new_file().path()))
.or_else(|| entry.path().map(std::path::Path::new))
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
let index_status = index_status_label(status);
let worktree_status = worktree_status_label(status);
let is_untracked = status.contains(git2::Status::WT_NEW);
let is_ignored = status.contains(git2::Status::IGNORED);
let is_conflicted = status.is_conflicted();
if index_status.is_some() {
index += 1;
}
if worktree_status.is_some() {
worktree += 1;
}
if is_untracked {
untracked += 1;
}
if is_ignored {
ignored += 1;
}
if is_conflicted {
conflicted += 1;
}
files.push(serde_json::json!({
"path": path,
"index_status": index_status,
"worktree_status": worktree_status,
"is_untracked": is_untracked,
"is_ignored": is_ignored,
"is_conflicted": is_conflicted,
}));
}
Ok(serde_json::json!({
"is_bare": false,
"head": head,
"is_dirty": !files.is_empty(),
"summary": {
"total": files.len(),
"index": index,
"worktree": worktree,
"untracked": untracked,
"ignored": ignored,
"conflicted": conflicted,
},
"files": files,
}))
}
fn index_status_label(status: git2::Status) -> Option<&'static str> {
if status.contains(git2::Status::INDEX_NEW) {
Some("added")
} else if status.contains(git2::Status::INDEX_MODIFIED) {
Some("modified")
} else if status.contains(git2::Status::INDEX_DELETED) {
Some("deleted")
} else if status.contains(git2::Status::INDEX_RENAMED) {
Some("renamed")
} else if status.contains(git2::Status::INDEX_TYPECHANGE) {
Some("typechange")
} else {
None
}
}
fn worktree_status_label(status: git2::Status) -> Option<&'static str> {
if status.contains(git2::Status::WT_NEW) {
Some("untracked")
} else if status.contains(git2::Status::WT_MODIFIED) {
Some("modified")
} else if status.contains(git2::Status::WT_DELETED) {
Some("deleted")
} else if status.contains(git2::Status::WT_RENAMED) {
Some("renamed")
} else if status.contains(git2::Status::WT_TYPECHANGE) {
Some("typechange")
} else if status.contains(git2::Status::IGNORED) {
Some("ignored")
} else {
None
}
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
registry.register(
ToolDefinition::new("git_status")
.description("Show repository working tree status: staged, unstaged, untracked, ignored, and conflicted files. Bare repositories return an empty clean status.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param("project_name", "string", "Project name (slug)", true),
param("repo_name", "string", "Repository name", true),
param("include_ignored", "boolean", "Include ignored files. Default false.", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
git_status_exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
})
}),
);
}
fn param(name: &str, param_type: &str, description: &str, required: bool) -> (String, ToolParam) {
(
name.into(),
ToolParam {
name: name.into(),
param_type: param_type.into(),
description: Some(description.into()),
required,
properties: None,
items: None,
},
)
}

View File

@ -0,0 +1,280 @@
//! Tool: project_bing_search - search the web with Bing Web Search API.
use agent::{ToolContext, ToolDefinition, ToolError, ToolParam, ToolSchema};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::OnceLock;
const DEFAULT_COUNT: u64 = 10;
const MAX_COUNT: u64 = 50;
const DEFAULT_ENDPOINT: &str = "https://api.bing.microsoft.com/v7.0/search";
static SHARED_CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
fn shared_client() -> &'static reqwest::Client {
SHARED_CLIENT.get_or_init(|| {
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("reqwest client build should not fail")
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingSearchResponse {
#[serde(default)]
web_pages: Option<BingWebPages>,
#[serde(default)]
query_context: Option<BingQueryContext>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingWebPages {
#[serde(default)]
total_estimated_matches: Option<u64>,
#[serde(default)]
value: Vec<BingWebResult>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingWebResult {
#[serde(default)]
name: String,
#[serde(default)]
url: String,
#[serde(default)]
display_url: String,
#[serde(default)]
snippet: String,
#[serde(default)]
date_last_crawled: Option<String>,
#[serde(default)]
language: Option<String>,
#[serde(default)]
is_family_friendly: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct BingQueryContext {
#[serde(default)]
original_query: String,
#[serde(default)]
altered_query: Option<String>,
}
pub async fn bing_search_exec(
ctx: ToolContext,
args: serde_json::Value,
) -> Result<serde_json::Value, ToolError> {
let query = args
.get("query")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::ExecutionError("query is required".into()))?;
let count = args
.get("count")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_COUNT)
.clamp(1, MAX_COUNT);
let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0);
let market = args
.get("market")
.and_then(|v| v.as_str())
.unwrap_or("en-US");
let safe_search = args
.get("safe_search")
.and_then(|v| v.as_str())
.unwrap_or("Moderate");
let freshness = args.get("freshness").and_then(|v| v.as_str());
let api_key = ctx
.config()
.env
.get("APP_BING_SEARCH_API_KEY")
.or_else(|| ctx.config().env.get("BING_SEARCH_API_KEY"))
.map(String::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
ToolError::ExecutionError(
"Bing search API key is required: set APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY"
.into(),
)
})?;
let endpoint = ctx
.config()
.env
.get("APP_BING_SEARCH_ENDPOINT")
.map(String::as_str)
.unwrap_or(DEFAULT_ENDPOINT);
let mut url = reqwest::Url::parse(endpoint)
.map_err(|e| ToolError::ExecutionError(format!("Invalid Bing endpoint: {}", e)))?;
{
let mut query_pairs = url.query_pairs_mut();
query_pairs
.append_pair("q", query)
.append_pair("count", &count.to_string())
.append_pair("offset", &offset.to_string())
.append_pair("mkt", market)
.append_pair("safeSearch", safe_search)
.append_pair("responseFilter", "Webpages")
.append_pair("textFormat", "Raw");
if let Some(freshness) = freshness {
query_pairs.append_pair("freshness", freshness);
}
}
let response = shared_client()
.get(url)
.header("Ocp-Apim-Subscription-Key", api_key)
.send()
.await
.map_err(|e| ToolError::ExecutionError(format!("Bing search request failed: {}", e)))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| ToolError::ExecutionError(format!("Failed to read Bing response: {}", e)))?;
if !status.is_success() {
return Err(ToolError::ExecutionError(format!(
"Bing search returned status {}: {}",
status,
truncate(&body, 500)
)));
}
let parsed: BingSearchResponse = serde_json::from_str(&body)
.map_err(|e| ToolError::ExecutionError(format!("Failed to parse Bing response: {}", e)))?;
let results = parsed
.web_pages
.as_ref()
.map(|pages| {
pages
.value
.iter()
.map(|item| {
serde_json::json!({
"title": item.name,
"url": item.url,
"display_url": item.display_url,
"snippet": item.snippet,
"date_last_crawled": item.date_last_crawled,
"language": item.language,
"is_family_friendly": item.is_family_friendly,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(serde_json::json!({
"query": query,
"original_query": parsed.query_context.as_ref().map(|q| q.original_query.as_str()).unwrap_or(query),
"altered_query": parsed.query_context.and_then(|q| q.altered_query),
"count": results.len(),
"total_estimated_matches": parsed.web_pages.and_then(|p| p.total_estimated_matches),
"results": results,
}))
}
fn truncate(s: &str, max_chars: usize) -> String {
let mut chars = s.chars();
let truncated: String = chars.by_ref().take(max_chars).collect();
if chars.next().is_some() {
format!("{}...", truncated)
} else {
truncated
}
}
pub fn tool_definition() -> ToolDefinition {
let mut p = HashMap::new();
p.insert(
"query".into(),
ToolParam {
name: "query".into(),
param_type: "string".into(),
description: Some("Web search query. Required.".into()),
required: true,
properties: None,
items: None,
},
);
p.insert(
"count".into(),
ToolParam {
name: "count".into(),
param_type: "integer".into(),
description: Some("Number of results to return. Default 10, max 50.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"offset".into(),
ToolParam {
name: "offset".into(),
param_type: "integer".into(),
description: Some("Result offset for pagination. Default 0.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"market".into(),
ToolParam {
name: "market".into(),
param_type: "string".into(),
description: Some("Market code such as en-US or zh-CN. Default en-US.".into()),
required: false,
properties: None,
items: None,
},
);
p.insert(
"safe_search".into(),
ToolParam {
name: "safe_search".into(),
param_type: "string".into(),
description: Some(
"Bing safe search level: Off, Moderate, or Strict. Default Moderate.".into(),
),
required: false,
properties: None,
items: None,
},
);
p.insert(
"freshness".into(),
ToolParam {
name: "freshness".into(),
param_type: "string".into(),
description: Some("Optional freshness filter: Day, Week, or Month.".into()),
required: false,
properties: None,
items: None,
},
);
ToolDefinition::new("project_bing_search")
.description(
"Search the public web with Bing Web Search API. Returns titles, URLs, snippets, crawl dates, and estimated match counts. Requires APP_BING_SEARCH_API_KEY or BING_SEARCH_API_KEY.",
)
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(p),
required: Some(vec!["query".into()]),
})
}

View File

@ -272,7 +272,7 @@ pub async fn curl_exec(
// ─── tool definition ─────────────────────────────────────────────────────────
pub fn tool_definition() -> ToolDefinition {
fn tool_definition_with_name(name: &str) -> ToolDefinition {
let mut p = HashMap::new();
p.insert(
"url".into(),
@ -332,7 +332,7 @@ pub fn tool_definition() -> ToolDefinition {
items: None,
},
);
ToolDefinition::new("project_curl")
ToolDefinition::new(name)
.description(
"Perform an HTTP request to any URL. Supports GET, POST, PUT, DELETE, PATCH, HEAD. \
Returns status code, headers, and response body. \
@ -345,3 +345,11 @@ pub fn tool_definition() -> ToolDefinition {
required: Some(vec!["url".into()]),
})
}
pub fn tool_definition() -> ToolDefinition {
tool_definition_with_name("project_curl")
}
pub fn alias_tool_definition() -> ToolDefinition {
tool_definition_with_name("curl_exec")
}

View File

@ -7,6 +7,7 @@
//! - list / create / update boards and board cards
mod arxiv;
mod bing;
mod boards;
mod curl;
mod issues;
@ -16,6 +17,7 @@ mod repos;
use agent::{ToolHandler, ToolRegistry};
pub use arxiv::arxiv_search_exec;
pub use bing::bing_search_exec;
pub use boards::{
create_board_card_exec, create_board_column_exec, create_board_exec, delete_board_card_exec,
list_boards_exec, update_board_card_exec, update_board_exec,
@ -34,10 +36,19 @@ pub fn register_all(registry: &mut ToolRegistry) {
ToolHandler::new(|ctx, args| Box::pin(arxiv_search_exec(ctx, args))),
);
registry.register(
bing::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(bing_search_exec(ctx, args))),
);
registry.register(
curl::tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
);
registry.register(
curl::alias_tool_definition(),
ToolHandler::new(|ctx, args| Box::pin(curl_exec(ctx, args))),
);
registry.register(
repos::list_tool_definition(),

View File

@ -23,12 +23,16 @@ use crate::GitDomain;
use sha1::Digest;
const SKILL_ROOTS: &[(&str, &str)] = &[(".claude/skills", "claude"), (".codex/skills", "codex")];
const ROOT_SKILL_SYSTEM: &str = "root";
fn should_descend_dir(name: &str) -> bool {
name != ".git"
}
/// Recursively scan `base` for files named `SKILL.md`.
/// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos.
/// Recursively scan supported skill locations for files named `SKILL.md`.
/// Root-level skill packs keep the legacy slug `{short_repo_id}/{skill_dir}`.
/// System skills use `{short_repo_id}/{system}/{relative_skill_dir}`.
fn scan_skills_from_dir(
base: &Path,
repo_id: &RepoId,
@ -36,8 +40,34 @@ fn scan_skills_from_dir(
) -> Result<Vec<DiscoveredSkill>, std::io::Error> {
let repo_id_prefix = &repo_id.to_string()[..8];
let mut discovered = Vec::new();
let mut stack = vec![base.to_path_buf()];
for (root, system) in SKILL_ROOTS {
let root_path = base.join(root);
if root_path.exists() {
scan_skill_root_from_dir(
&root_path,
repo_id_prefix,
system,
root,
commit_sha,
&mut discovered,
);
}
}
scan_root_skill_pack_from_dir(base, repo_id_prefix, commit_sha, &mut discovered);
Ok(discovered)
}
fn scan_skill_root_from_dir(
root_path: &Path,
repo_id_prefix: &str,
system: &str,
root: &str,
commit_sha: &str,
discovered: &mut Vec<DiscoveredSkill>,
) {
let mut stack = vec![root_path.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
@ -47,30 +77,82 @@ fn scan_skills_from_dir(
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path
.file_name()
.and_then(|n| n.to_str())
.map(|x| x.to_lowercase())
== Some("skill.md".to_string())
{
if let Some(dir_name) = path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
{
let slug = format!("{}/{}", repo_id_prefix, dir_name);
if let Ok(raw) = std::fs::read(&path) {
let blob_hash = git_blob_hash(&raw);
let mut skill = parse_skill_content(&slug, &raw);
skill.commit_sha = Some(commit_sha.to_string());
skill.blob_hash = Some(blob_hash);
discovered.push(skill);
}
}
continue;
}
if !is_skill_file_name(&path) {
continue;
}
let Some(parent) = path.parent() else {
continue;
};
let relative_skill_dir = parent
.strip_prefix(root_path)
.ok()
.and_then(path_to_slug)
.filter(|s| !s.is_empty());
let Some(relative_skill_dir) = relative_skill_dir else {
continue;
};
let slug = format!("{}/{}/{}", repo_id_prefix, system, relative_skill_dir);
if let Ok(raw) = std::fs::read(&path) {
let blob_hash = git_blob_hash(&raw);
let mut skill = parse_skill_content(&slug, &raw);
skill.commit_sha = Some(commit_sha.to_string());
skill.blob_hash = Some(blob_hash);
skill.metadata = enrich_metadata(
skill.metadata,
system,
Some(&format!("{}/{}/SKILL.md", root, relative_skill_dir)),
);
discovered.push(skill);
}
}
}
Ok(discovered)
}
fn scan_root_skill_pack_from_dir(
base: &Path,
repo_id_prefix: &str,
commit_sha: &str,
discovered: &mut Vec<DiscoveredSkill>,
) {
let entries = match std::fs::read_dir(base) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if dir_name == ".git" || dir_name == ".claude" || dir_name == ".codex" {
continue;
}
let skill_file = path.join("SKILL.md");
if !skill_file.exists() {
continue;
}
let relative_skill_dir = slugify_segment(dir_name);
if relative_skill_dir.is_empty() {
continue;
}
let slug = format!("{}/{}", repo_id_prefix, relative_skill_dir);
if let Ok(raw) = std::fs::read(&skill_file) {
let blob_hash = git_blob_hash(&raw);
let mut skill = parse_skill_content(&slug, &raw);
skill.commit_sha = Some(commit_sha.to_string());
skill.blob_hash = Some(blob_hash);
skill.metadata = enrich_metadata(
skill.metadata,
ROOT_SKILL_SYSTEM,
Some(&format!("{}/SKILL.md", relative_skill_dir)),
);
discovered.push(skill);
}
}
}
fn git_blob_hash(content: &[u8]) -> String {
@ -126,6 +208,57 @@ struct DiscoveredSkill {
blob_hash: Option<String>,
}
fn is_skill_file_name(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
fn path_to_slug(path: &Path) -> Option<String> {
let parts: Vec<String> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.map(slugify_segment)
.filter(|s| !s.is_empty())
.collect();
(!parts.is_empty()).then(|| parts.join("/"))
}
fn slugify_segment(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut last_dash = false;
for ch in input.chars() {
let ch = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
out.push(ch);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn enrich_metadata(
mut metadata: serde_json::Value,
system: &str,
relative_path: Option<&str>,
) -> serde_json::Value {
if !metadata.is_object() {
metadata = serde_json::json!({});
}
if let Some(obj) = metadata.as_object_mut() {
obj.entry("system")
.or_insert_with(|| serde_json::Value::String(system.to_string()));
if let Some(relative_path) = relative_path {
obj.entry("path")
.or_insert_with(|| serde_json::Value::String(relative_path.to_string()));
}
}
metadata
}
fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
@ -175,20 +308,25 @@ fn scan_skills_from_tree(
}
}
}
Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => {
let dir_name = std::path::Path::new(&entry_path)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str());
let Some(dir_name) = dir_name else { continue };
Some(git2::ObjectType::Blob) if name.eq_ignore_ascii_case("SKILL.md") => {
let Some((system, relative_skill_dir, legacy_slug)) =
skill_location_from_path(&entry_path)
else {
continue;
};
let slug = format!("{}/{}", repo_id_prefix, dir_name);
let slug = if legacy_slug {
format!("{}/{}", repo_id_prefix, relative_skill_dir)
} else {
format!("{}/{}/{}", repo_id_prefix, system, relative_skill_dir)
};
if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) {
let raw = blob.content();
let blob_hash = git_blob_hash(raw);
let mut skill = parse_skill_content(&slug, raw);
skill.commit_sha = Some(commit_sha.to_string());
skill.blob_hash = Some(blob_hash);
skill.metadata = enrich_metadata(skill.metadata, system, Some(&entry_path));
discovered.push(skill);
}
}
@ -200,6 +338,39 @@ fn scan_skills_from_tree(
Ok(discovered)
}
fn skill_location_from_path(path: &str) -> Option<(&'static str, String, bool)> {
let normalized = path.replace('\\', "/");
for (root, system) in SKILL_ROOTS {
let prefix = format!("{}/", root);
let suffix = "/SKILL.md";
if normalized.starts_with(&prefix) && normalized.ends_with(suffix) {
let relative = &normalized[prefix.len()..normalized.len() - suffix.len()];
let slug = relative
.split('/')
.map(slugify_segment)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("/");
if !slug.is_empty() {
return Some((*system, slug, false));
}
}
}
let suffix = "/SKILL.md";
if normalized.ends_with(suffix) && !normalized.starts_with('.') {
let relative = &normalized[..normalized.len() - suffix.len()];
if !relative.contains('/') {
let slug = slugify_segment(relative);
if !slug.is_empty() {
return Some((ROOT_SKILL_SYSTEM, slug, true));
}
}
}
None
}
#[derive(Clone)]
pub struct HookMetaDataSync {
pub db: AppDatabase,
@ -414,40 +585,27 @@ impl HookMetaDataSync {
}
};
// Deduplicate by {repo_id}+{blob_hash}, keep latest by commit_sha
// Deduplicate by stable slug. Blob hash changes when content changes and must not be the
// upsert key because project_skill has a unique (project_uuid, slug) constraint.
let mut deduped: std::collections::HashMap<String, DiscoveredSkill> =
std::collections::HashMap::new();
for skill in discovered {
let key = if let Some(ref hash) = skill.blob_hash {
format!("{}:{}", self.repo.id, hash)
} else {
format!("{}:{}:slug", self.repo.id, skill.slug)
};
match deduped.get(&key) {
match deduped.get(&skill.slug) {
Some(existing) => {
if skill.commit_sha.as_ref().unwrap_or(&String::new())
> existing.commit_sha.as_ref().unwrap_or(&String::new())
{
deduped.insert(key, skill);
deduped.insert(skill.slug.clone(), skill);
}
}
None => {
deduped.insert(key, skill);
deduped.insert(skill.slug.clone(), skill);
}
}
}
let existing_by_hash: HashMap<_, _> = existing
.into_iter()
.map(|s| {
let key = format!(
"{}:{}",
s.repo_id.unwrap_or_default(),
s.blob_hash.clone().unwrap_or_default()
);
(key, s)
})
.collect();
let existing_by_slug: HashMap<_, _> =
existing.into_iter().map(|s| (s.slug.clone(), s)).collect();
let mut seen_keys = std::collections::HashSet::new();
@ -455,12 +613,17 @@ impl HookMetaDataSync {
seen_keys.insert(key.clone());
let json_meta = serde_json::to_value(&skill.metadata).unwrap_or_default();
if let Some(existing_skill) = existing_by_hash.get(&key) {
if let Some(existing_skill) = existing_by_slug.get(&key) {
if existing_skill.content != skill.content
|| existing_skill.metadata != json_meta
|| existing_skill.commit_sha != skill.commit_sha
|| existing_skill.blob_hash != skill.blob_hash
|| existing_skill.name != skill.name
|| existing_skill.description != skill.description
{
let mut active: SkillActiveModel = existing_skill.clone().into();
active.name = Set(skill.name);
active.description = Set(skill.description);
active.content = Set(skill.content);
active.metadata = Set(json_meta);
active.commit_sha = Set(skill.commit_sha);
@ -494,7 +657,7 @@ impl HookMetaDataSync {
}
}
for (key, old_skill) in existing_by_hash {
for (key, old_skill) in existing_by_slug {
if !seen_keys.contains(&key) {
if SkillEntity::delete_by_id(old_skill.id)
.exec(&self.db)

View File

@ -4,27 +4,24 @@ pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"room_compact_summary"
"bootstrap"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!("sql/room_compact_summary.sql");
super::execute_sql(manager, sql).await?;
manager
.get_connection()
.execute_unprepared(include_str!("sql/bootstrap/bootstrap_up_01.sql"))
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_room_compact_summary_room_to_seq;
DROP TABLE IF EXISTS room_compact_summary;
"#,
)
.execute_unprepared(include_str!("sql/bootstrap/bootstrap_down_01.sql"))
.await?;
Ok(())
}

View File

@ -1,54 +0,0 @@
//! SeaORM migration: create room_notifications table
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"init"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!("sql/init.sql");
super::execute_sql(manager, sql).await?;
let e = "
CREATE OR REPLACE FUNCTION room_message_tsv_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
NEW.content_tsv := to_tsvector('english', NEW.content);
RETURN NEW;
END;
$function$;
create or replace trigger room_message_tsv_update
before insert or update
on room_message
for each row
execute procedure room_message_tsv_trigger();
DO
$$
BEGIN
IF NOT EXISTS (SELECT 1
FROM pg_type
WHERE typname = 'notification_type'
AND typtype = 'e') THEN
CREATE TYPE notification_type AS ENUM (
'mention',
'invitation',
'role_change',
'room_created',
'room_deleted',
'system_announcement'
);
END IF;
END
$$;";
manager.get_connection().execute_unprepared(e).await?;
Ok(())
}
}

View File

@ -35,23 +35,267 @@ mod tests {
}
}
pub struct Migrator;
macro_rules! define_sql_migrations {
($( $module:ident => { name: $name:literal, up: $up:literal, down: $down:literal } ),+ $(,)?) => {
$(
pub mod $module {
use sea_orm_migration::prelude::*;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(init::Migration),
Box::new(room_compact_summary::Migration),
Box::new(user_billing_history::Migration),
Box::new(project_message_favorite::Migration),
Box::new(ai_subagent_session::Migration),
]
}
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
$name
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!($up);
super::execute_sql(manager, sql).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let sql = include_str!($down);
super::execute_sql(manager, sql).await?;
Ok(())
}
}
}
)+
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(user::Migration),
Box::new(user_password::Migration),
Box::new(user_email::Migration),
Box::new(user_2fa::Migration),
Box::new(user_notification::Migration),
Box::new(user_preferences::Migration),
Box::new(user_password_reset::Migration),
Box::new(user_relation::Migration),
Box::new(user_ssh_key::Migration),
Box::new(user_token::Migration),
Box::new(user_activity_log::Migration),
Box::new(project_access_log::Migration),
Box::new(project_audit_log::Migration),
Box::new(project_billing::Migration),
Box::new(project_billing_history::Migration),
Box::new(project_follow::Migration),
Box::new(project_history_name::Migration),
Box::new(project_label::Migration),
Box::new(project_like::Migration),
Box::new(project_member_invitations::Migration),
Box::new(project_member_join_answers::Migration),
Box::new(project_member_join_request::Migration),
Box::new(project_member_join_settings::Migration),
Box::new(project_members::Migration),
Box::new(project_watch::Migration),
Box::new(repo::Migration),
Box::new(repo_branch::Migration),
Box::new(repo_branch_protect::Migration),
Box::new(repo_collaborator::Migration),
Box::new(repo_commit::Migration),
Box::new(repo_fork::Migration),
Box::new(repo_history_name::Migration),
Box::new(repo_hook::Migration),
Box::new(repo_lfs_lock::Migration),
Box::new(repo_lfs_object::Migration),
Box::new(repo_lock::Migration),
Box::new(repo_star::Migration),
Box::new(repo_tag::Migration),
Box::new(repo_upstream::Migration),
Box::new(repo_watch::Migration),
Box::new(repo_webhook::Migration),
Box::new(issue::Migration),
Box::new(issue_assignee::Migration),
Box::new(issue_comment::Migration),
Box::new(issue_comment_reaction::Migration),
Box::new(issue_label::Migration),
Box::new(issue_pull_request::Migration),
Box::new(issue_reaction::Migration),
Box::new(issue_repo::Migration),
Box::new(issue_subscriber::Migration),
Box::new(pull_request::Migration),
Box::new(pull_request_commit::Migration),
Box::new(pull_request_review::Migration),
Box::new(pull_request_review_comment::Migration),
Box::new(room_category::Migration),
Box::new(room::Migration),
Box::new(room_ai::Migration),
Box::new(room_message::Migration),
Box::new(room_pin::Migration),
Box::new(room_thread::Migration),
Box::new(ai_model_provider::Migration),
Box::new(ai_model::Migration),
Box::new(ai_model_version::Migration),
Box::new(ai_model_capability::Migration),
Box::new(ai_model_parameter_profile::Migration),
Box::new(ai_model_pricing::Migration),
Box::new(ai_session::Migration),
Box::new(ai_tool_call::Migration),
Box::new(ai_tool_auth::Migration),
Box::new(label::Migration),
Box::new(notify::Migration),
Box::new(room_notifications::Migration),
Box::new(user_email_change::Migration),
Box::new(project_activity::Migration),
Box::new(room_message_reaction::Migration),
Box::new(room_message_edit_history::Migration),
Box::new(project_board::Migration),
Box::new(project_board_column::Migration),
Box::new(project_board_card::Migration),
Box::new(pull_request_review_request::Migration),
Box::new(workspace::Migration),
Box::new(project::Migration),
Box::new(workspace_membership::Migration),
Box::new(workspace_billing::Migration),
Box::new(workspace_billing_history::Migration),
Box::new(project_skill::Migration),
Box::new(agent_task::Migration),
Box::new(admin_user::Migration),
Box::new(admin_role::Migration),
Box::new(admin_permission::Migration),
Box::new(admin_user_role::Migration),
Box::new(admin_role_permission::Migration),
Box::new(admin_audit_log::Migration),
Box::new(admin_api_token::Migration),
Box::new(workspace_alert_config::Migration),
Box::new(room_attachment::Migration),
Box::new(room_access::Migration),
Box::new(room_user_state::Migration),
Box::new(project_role_priority::Migration),
Box::new(ai_conversation::Migration),
Box::new(ai_message::Migration),
Box::new(ai_message_fork::Migration),
Box::new(ai_shared_conversation::Migration),
Box::new(ai_token_usage::Migration),
Box::new(room_compact_summary::Migration),
Box::new(user_billing_history::Migration),
Box::new(project_message_favorite::Migration),
Box::new(ai_subagent_session::Migration),
Box::new(bootstrap::Migration),
]
}
}
};
}
pub mod ai_subagent_session;
pub mod init;
pub mod project_message_favorite;
pub mod room_compact_summary;
pub mod user_billing_history;
define_sql_migrations! {
user => { name: "user", up: "sql/user/user_up_01.sql", down: "sql/user/user_down_01.sql" },
user_password => { name: "user_password", up: "sql/user_password/user_password_up_01.sql", down: "sql/user_password/user_password_down_01.sql" },
user_email => { name: "user_email", up: "sql/user_email/user_email_up_01.sql", down: "sql/user_email/user_email_down_01.sql" },
user_2fa => { name: "user_2fa", up: "sql/user_2fa/user_2fa_up_01.sql", down: "sql/user_2fa/user_2fa_down_01.sql" },
user_notification => { name: "user_notification", up: "sql/user_notification/user_notification_up_01.sql", down: "sql/user_notification/user_notification_down_01.sql" },
user_preferences => { name: "user_preferences", up: "sql/user_preferences/user_preferences_up_01.sql", down: "sql/user_preferences/user_preferences_down_01.sql" },
user_password_reset => { name: "user_password_reset", up: "sql/user_password_reset/user_password_reset_up_01.sql", down: "sql/user_password_reset/user_password_reset_down_01.sql" },
user_relation => { name: "user_relation", up: "sql/user_relation/user_relation_up_01.sql", down: "sql/user_relation/user_relation_down_01.sql" },
user_ssh_key => { name: "user_ssh_key", up: "sql/user_ssh_key/user_ssh_key_up_01.sql", down: "sql/user_ssh_key/user_ssh_key_down_01.sql" },
user_token => { name: "user_token", up: "sql/user_token/user_token_up_01.sql", down: "sql/user_token/user_token_down_01.sql" },
user_activity_log => { name: "user_activity_log", up: "sql/user_activity_log/user_activity_log_up_01.sql", down: "sql/user_activity_log/user_activity_log_down_01.sql" },
project_access_log => { name: "project_access_log", up: "sql/project_access_log/project_access_log_up_01.sql", down: "sql/project_access_log/project_access_log_down_01.sql" },
project_audit_log => { name: "project_audit_log", up: "sql/project_audit_log/project_audit_log_up_01.sql", down: "sql/project_audit_log/project_audit_log_down_01.sql" },
project_billing => { name: "project_billing", up: "sql/project_billing/project_billing_up_01.sql", down: "sql/project_billing/project_billing_down_01.sql" },
project_billing_history => { name: "project_billing_history", up: "sql/project_billing_history/project_billing_history_up_01.sql", down: "sql/project_billing_history/project_billing_history_down_01.sql" },
project_follow => { name: "project_follow", up: "sql/project_follow/project_follow_up_01.sql", down: "sql/project_follow/project_follow_down_01.sql" },
project_history_name => { name: "project_history_name", up: "sql/project_history_name/project_history_name_up_01.sql", down: "sql/project_history_name/project_history_name_down_01.sql" },
project_label => { name: "project_label", up: "sql/project_label/project_label_up_01.sql", down: "sql/project_label/project_label_down_01.sql" },
project_like => { name: "project_like", up: "sql/project_like/project_like_up_01.sql", down: "sql/project_like/project_like_down_01.sql" },
project_member_invitations => { name: "project_member_invitations", up: "sql/project_member_invitations/project_member_invitations_up_01.sql", down: "sql/project_member_invitations/project_member_invitations_down_01.sql" },
project_member_join_answers => { name: "project_member_join_answers", up: "sql/project_member_join_answers/project_member_join_answers_up_01.sql", down: "sql/project_member_join_answers/project_member_join_answers_down_01.sql" },
project_member_join_request => { name: "project_member_join_request", up: "sql/project_member_join_request/project_member_join_request_up_01.sql", down: "sql/project_member_join_request/project_member_join_request_down_01.sql" },
project_member_join_settings => { name: "project_member_join_settings", up: "sql/project_member_join_settings/project_member_join_settings_up_01.sql", down: "sql/project_member_join_settings/project_member_join_settings_down_01.sql" },
project_members => { name: "project_members", up: "sql/project_members/project_members_up_01.sql", down: "sql/project_members/project_members_down_01.sql" },
project_watch => { name: "project_watch", up: "sql/project_watch/project_watch_up_01.sql", down: "sql/project_watch/project_watch_down_01.sql" },
repo => { name: "repo", up: "sql/repo/repo_up_01.sql", down: "sql/repo/repo_down_01.sql" },
repo_branch => { name: "repo_branch", up: "sql/repo_branch/repo_branch_up_01.sql", down: "sql/repo_branch/repo_branch_down_01.sql" },
repo_branch_protect => { name: "repo_branch_protect", up: "sql/repo_branch_protect/repo_branch_protect_up_01.sql", down: "sql/repo_branch_protect/repo_branch_protect_down_01.sql" },
repo_collaborator => { name: "repo_collaborator", up: "sql/repo_collaborator/repo_collaborator_up_01.sql", down: "sql/repo_collaborator/repo_collaborator_down_01.sql" },
repo_commit => { name: "repo_commit", up: "sql/repo_commit/repo_commit_up_01.sql", down: "sql/repo_commit/repo_commit_down_01.sql" },
repo_fork => { name: "repo_fork", up: "sql/repo_fork/repo_fork_up_01.sql", down: "sql/repo_fork/repo_fork_down_01.sql" },
repo_history_name => { name: "repo_history_name", up: "sql/repo_history_name/repo_history_name_up_01.sql", down: "sql/repo_history_name/repo_history_name_down_01.sql" },
repo_hook => { name: "repo_hook", up: "sql/repo_hook/repo_hook_up_01.sql", down: "sql/repo_hook/repo_hook_down_01.sql" },
repo_lfs_lock => { name: "repo_lfs_lock", up: "sql/repo_lfs_lock/repo_lfs_lock_up_01.sql", down: "sql/repo_lfs_lock/repo_lfs_lock_down_01.sql" },
repo_lfs_object => { name: "repo_lfs_object", up: "sql/repo_lfs_object/repo_lfs_object_up_01.sql", down: "sql/repo_lfs_object/repo_lfs_object_down_01.sql" },
repo_lock => { name: "repo_lock", up: "sql/repo_lock/repo_lock_up_01.sql", down: "sql/repo_lock/repo_lock_down_01.sql" },
repo_star => { name: "repo_star", up: "sql/repo_star/repo_star_up_01.sql", down: "sql/repo_star/repo_star_down_01.sql" },
repo_tag => { name: "repo_tag", up: "sql/repo_tag/repo_tag_up_01.sql", down: "sql/repo_tag/repo_tag_down_01.sql" },
repo_upstream => { name: "repo_upstream", up: "sql/repo_upstream/repo_upstream_up_01.sql", down: "sql/repo_upstream/repo_upstream_down_01.sql" },
repo_watch => { name: "repo_watch", up: "sql/repo_watch/repo_watch_up_01.sql", down: "sql/repo_watch/repo_watch_down_01.sql" },
repo_webhook => { name: "repo_webhook", up: "sql/repo_webhook/repo_webhook_up_01.sql", down: "sql/repo_webhook/repo_webhook_down_01.sql" },
issue => { name: "issue", up: "sql/issue/issue_up_01.sql", down: "sql/issue/issue_down_01.sql" },
issue_assignee => { name: "issue_assignee", up: "sql/issue_assignee/issue_assignee_up_01.sql", down: "sql/issue_assignee/issue_assignee_down_01.sql" },
issue_comment => { name: "issue_comment", up: "sql/issue_comment/issue_comment_up_01.sql", down: "sql/issue_comment/issue_comment_down_01.sql" },
issue_comment_reaction => { name: "issue_comment_reaction", up: "sql/issue_comment_reaction/issue_comment_reaction_up_01.sql", down: "sql/issue_comment_reaction/issue_comment_reaction_down_01.sql" },
issue_label => { name: "issue_label", up: "sql/issue_label/issue_label_up_01.sql", down: "sql/issue_label/issue_label_down_01.sql" },
issue_pull_request => { name: "issue_pull_request", up: "sql/issue_pull_request/issue_pull_request_up_01.sql", down: "sql/issue_pull_request/issue_pull_request_down_01.sql" },
issue_reaction => { name: "issue_reaction", up: "sql/issue_reaction/issue_reaction_up_01.sql", down: "sql/issue_reaction/issue_reaction_down_01.sql" },
issue_repo => { name: "issue_repo", up: "sql/issue_repo/issue_repo_up_01.sql", down: "sql/issue_repo/issue_repo_down_01.sql" },
issue_subscriber => { name: "issue_subscriber", up: "sql/issue_subscriber/issue_subscriber_up_01.sql", down: "sql/issue_subscriber/issue_subscriber_down_01.sql" },
pull_request => { name: "pull_request", up: "sql/pull_request/pull_request_up_01.sql", down: "sql/pull_request/pull_request_down_01.sql" },
pull_request_commit => { name: "pull_request_commit", up: "sql/pull_request_commit/pull_request_commit_up_01.sql", down: "sql/pull_request_commit/pull_request_commit_down_01.sql" },
pull_request_review => { name: "pull_request_review", up: "sql/pull_request_review/pull_request_review_up_01.sql", down: "sql/pull_request_review/pull_request_review_down_01.sql" },
pull_request_review_comment => { name: "pull_request_review_comment", up: "sql/pull_request_review_comment/pull_request_review_comment_up_01.sql", down: "sql/pull_request_review_comment/pull_request_review_comment_down_01.sql" },
room_category => { name: "room_category", up: "sql/room_category/room_category_up_01.sql", down: "sql/room_category/room_category_down_01.sql" },
room => { name: "room", up: "sql/room/room_up_01.sql", down: "sql/room/room_down_01.sql" },
room_ai => { name: "room_ai", up: "sql/room_ai/room_ai_up_01.sql", down: "sql/room_ai/room_ai_down_01.sql" },
room_message => { name: "room_message", up: "sql/room_message/room_message_up_01.sql", down: "sql/room_message/room_message_down_01.sql" },
room_pin => { name: "room_pin", up: "sql/room_pin/room_pin_up_01.sql", down: "sql/room_pin/room_pin_down_01.sql" },
room_thread => { name: "room_thread", up: "sql/room_thread/room_thread_up_01.sql", down: "sql/room_thread/room_thread_down_01.sql" },
ai_model_provider => { name: "ai_model_provider", up: "sql/ai_model_provider/ai_model_provider_up_01.sql", down: "sql/ai_model_provider/ai_model_provider_down_01.sql" },
ai_model => { name: "ai_model", up: "sql/ai_model/ai_model_up_01.sql", down: "sql/ai_model/ai_model_down_01.sql" },
ai_model_version => { name: "ai_model_version", up: "sql/ai_model_version/ai_model_version_up_01.sql", down: "sql/ai_model_version/ai_model_version_down_01.sql" },
ai_model_capability => { name: "ai_model_capability", up: "sql/ai_model_capability/ai_model_capability_up_01.sql", down: "sql/ai_model_capability/ai_model_capability_down_01.sql" },
ai_model_parameter_profile => { name: "ai_model_parameter_profile", up: "sql/ai_model_parameter_profile/ai_model_parameter_profile_up_01.sql", down: "sql/ai_model_parameter_profile/ai_model_parameter_profile_down_01.sql" },
ai_model_pricing => { name: "ai_model_pricing", up: "sql/ai_model_pricing/ai_model_pricing_up_01.sql", down: "sql/ai_model_pricing/ai_model_pricing_down_01.sql" },
ai_session => { name: "ai_session", up: "sql/ai_session/ai_session_up_01.sql", down: "sql/ai_session/ai_session_down_01.sql" },
ai_tool_call => { name: "ai_tool_call", up: "sql/ai_tool_call/ai_tool_call_up_01.sql", down: "sql/ai_tool_call/ai_tool_call_down_01.sql" },
ai_tool_auth => { name: "ai_tool_auth", up: "sql/ai_tool_auth/ai_tool_auth_up_01.sql", down: "sql/ai_tool_auth/ai_tool_auth_down_01.sql" },
label => { name: "label", up: "sql/label/label_up_01.sql", down: "sql/label/label_down_01.sql" },
notify => { name: "notify", up: "sql/notify/notify_up_01.sql", down: "sql/notify/notify_down_01.sql" },
room_notifications => { name: "room_notifications", up: "sql/room_notifications/room_notifications_up_01.sql", down: "sql/room_notifications/room_notifications_down_01.sql" },
user_email_change => { name: "user_email_change", up: "sql/user_email_change/user_email_change_up_01.sql", down: "sql/user_email_change/user_email_change_down_01.sql" },
project_activity => { name: "project_activity", up: "sql/project_activity/project_activity_up_01.sql", down: "sql/project_activity/project_activity_down_01.sql" },
room_message_reaction => { name: "room_message_reaction", up: "sql/room_message_reaction/room_message_reaction_up_01.sql", down: "sql/room_message_reaction/room_message_reaction_down_01.sql" },
room_message_edit_history => { name: "room_message_edit_history", up: "sql/room_message_edit_history/room_message_edit_history_up_01.sql", down: "sql/room_message_edit_history/room_message_edit_history_down_01.sql" },
project_board => { name: "project_board", up: "sql/project_board/project_board_up_01.sql", down: "sql/project_board/project_board_down_01.sql" },
project_board_column => { name: "project_board_column", up: "sql/project_board_column/project_board_column_up_01.sql", down: "sql/project_board_column/project_board_column_down_01.sql" },
project_board_card => { name: "project_board_card", up: "sql/project_board_card/project_board_card_up_01.sql", down: "sql/project_board_card/project_board_card_down_01.sql" },
pull_request_review_request => { name: "pull_request_review_request", up: "sql/pull_request_review_request/pull_request_review_request_up_01.sql", down: "sql/pull_request_review_request/pull_request_review_request_down_01.sql" },
workspace => { name: "workspace", up: "sql/workspace/workspace_up_01.sql", down: "sql/workspace/workspace_down_01.sql" },
project => { name: "project", up: "sql/project/project_up_01.sql", down: "sql/project/project_down_01.sql" },
workspace_membership => { name: "workspace_membership", up: "sql/workspace_membership/workspace_membership_up_01.sql", down: "sql/workspace_membership/workspace_membership_down_01.sql" },
workspace_billing => { name: "workspace_billing", up: "sql/workspace_billing/workspace_billing_up_01.sql", down: "sql/workspace_billing/workspace_billing_down_01.sql" },
workspace_billing_history => { name: "workspace_billing_history", up: "sql/workspace_billing_history/workspace_billing_history_up_01.sql", down: "sql/workspace_billing_history/workspace_billing_history_down_01.sql" },
project_skill => { name: "project_skill", up: "sql/project_skill/project_skill_up_01.sql", down: "sql/project_skill/project_skill_down_01.sql" },
agent_task => { name: "agent_task", up: "sql/agent_task/agent_task_up_01.sql", down: "sql/agent_task/agent_task_down_01.sql" },
admin_user => { name: "admin_user", up: "sql/admin_user/admin_user_up_01.sql", down: "sql/admin_user/admin_user_down_01.sql" },
admin_role => { name: "admin_role", up: "sql/admin_role/admin_role_up_01.sql", down: "sql/admin_role/admin_role_down_01.sql" },
admin_permission => { name: "admin_permission", up: "sql/admin_permission/admin_permission_up_01.sql", down: "sql/admin_permission/admin_permission_down_01.sql" },
admin_user_role => { name: "admin_user_role", up: "sql/admin_user_role/admin_user_role_up_01.sql", down: "sql/admin_user_role/admin_user_role_down_01.sql" },
admin_role_permission => { name: "admin_role_permission", up: "sql/admin_role_permission/admin_role_permission_up_01.sql", down: "sql/admin_role_permission/admin_role_permission_down_01.sql" },
admin_audit_log => { name: "admin_audit_log", up: "sql/admin_audit_log/admin_audit_log_up_01.sql", down: "sql/admin_audit_log/admin_audit_log_down_01.sql" },
admin_api_token => { name: "admin_api_token", up: "sql/admin_api_token/admin_api_token_up_01.sql", down: "sql/admin_api_token/admin_api_token_down_01.sql" },
workspace_alert_config => { name: "workspace_alert_config", up: "sql/workspace_alert_config/workspace_alert_config_up_01.sql", down: "sql/workspace_alert_config/workspace_alert_config_down_01.sql" },
room_attachment => { name: "room_attachment", up: "sql/room_attachment/room_attachment_up_01.sql", down: "sql/room_attachment/room_attachment_down_01.sql" },
room_access => { name: "room_access", up: "sql/room_access/room_access_up_01.sql", down: "sql/room_access/room_access_down_01.sql" },
room_user_state => { name: "room_user_state", up: "sql/room_user_state/room_user_state_up_01.sql", down: "sql/room_user_state/room_user_state_down_01.sql" },
project_role_priority => { name: "project_role_priority", up: "sql/project_role_priority/project_role_priority_up_01.sql", down: "sql/project_role_priority/project_role_priority_down_01.sql" },
ai_conversation => { name: "ai_conversation", up: "sql/ai_conversation/ai_conversation_up_01.sql", down: "sql/ai_conversation/ai_conversation_down_01.sql" },
ai_message => { name: "ai_message", up: "sql/ai_message/ai_message_up_01.sql", down: "sql/ai_message/ai_message_down_01.sql" },
ai_message_fork => { name: "ai_message_fork", up: "sql/ai_message_fork/ai_message_fork_up_01.sql", down: "sql/ai_message_fork/ai_message_fork_down_01.sql" },
ai_shared_conversation => { name: "ai_shared_conversation", up: "sql/ai_shared_conversation/ai_shared_conversation_up_01.sql", down: "sql/ai_shared_conversation/ai_shared_conversation_down_01.sql" },
ai_token_usage => { name: "ai_token_usage", up: "sql/ai_token_usage/ai_token_usage_up_01.sql", down: "sql/ai_token_usage/ai_token_usage_down_01.sql" },
room_compact_summary => { name: "room_compact_summary", up: "sql/room_compact_summary/room_compact_summary_up_01.sql", down: "sql/room_compact_summary/room_compact_summary_down_01.sql" },
user_billing_history => { name: "user_billing_history", up: "sql/user_billing_history/user_billing_history_up_01.sql", down: "sql/user_billing_history/user_billing_history_down_01.sql" },
project_message_favorite => { name: "project_message_favorite", up: "sql/project_message_favorite/project_message_favorite_up_01.sql", down: "sql/project_message_favorite/project_message_favorite_down_01.sql" },
ai_subagent_session => { name: "ai_subagent_session", up: "sql/ai_subagent_session/ai_subagent_session_up_01.sql", down: "sql/ai_subagent_session/ai_subagent_session_down_01.sql" },
}
pub mod bootstrap;

View File

@ -1,57 +0,0 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"project_message_favorite"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
create table if not exists project_message_favorite
(
uid uuid not null
primary key,
project uuid not null,
room uuid not null,
message uuid not null,
user_uuid uuid not null,
created_at timestamp with time zone not null
);
create unique index if not exists idx_project_message_favorite_user_message
on project_message_favorite (user_uuid, message);
create index if not exists idx_project_message_favorite_project_user
on project_message_favorite (project, user_uuid, created_at desc);
create index if not exists idx_project_message_favorite_room
on project_message_favorite (room);
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
drop index if exists idx_project_message_favorite_room;
drop index if exists idx_project_message_favorite_project_user;
drop index if exists idx_project_message_favorite_user_message;
drop table if exists project_message_favorite;
"#,
)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_admin_api_token_hash;
DROP TABLE IF EXISTS admin_api_token;

View File

@ -0,0 +1,22 @@
create table if not exists admin_api_token
(
id serial
primary key,
name varchar(255) not null,
token_hash varchar(64) not null
unique,
token_prefix varchar(32) not null,
permissions text[] default '{}'::text[] not null,
created_by integer not null
references admin_user
on delete set null,
created_at timestamp with time zone default now() not null,
last_used_at timestamp with time zone,
expires_at timestamp with time zone,
is_active boolean default true not null
);
comment on table admin_api_token is 'Admin API Token for programmatic access';
create index if not exists idx_admin_api_token_hash
on admin_api_token (token_hash);

View File

@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_admin_audit_log_resource;
DROP INDEX IF EXISTS idx_admin_audit_log_action;
DROP INDEX IF EXISTS idx_admin_audit_log_created_at;
DROP INDEX IF EXISTS idx_admin_audit_log_user_id;
DROP TABLE IF EXISTS admin_audit_log;

View File

@ -0,0 +1,28 @@
create table if not exists admin_audit_log
(
id bigserial
primary key,
user_id integer not null,
username varchar(255) not null,
action varchar(50) not null,
resource varchar(255) not null,
resource_id varchar(255),
request_params jsonb,
ip_address varchar(255),
user_agent text,
result varchar(20) default 'success'::character varying not null,
error_message text,
created_at timestamp with time zone default now() not null
);
create index if not exists idx_admin_audit_log_user_id
on admin_audit_log (user_id);
create index if not exists idx_admin_audit_log_created_at
on admin_audit_log (created_at desc);
create index if not exists idx_admin_audit_log_action
on admin_audit_log (action);
create index if not exists idx_admin_audit_log_resource
on admin_audit_log (resource);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_permission;

View File

@ -0,0 +1,10 @@
create table if not exists admin_permission
(
id serial
primary key,
name varchar(255) not null,
code varchar(255) not null
unique,
description text,
created_at timestamp with time zone default now() not null
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_role;

View File

@ -0,0 +1,9 @@
create table if not exists admin_role
(
id serial
primary key,
name varchar(255) not null
unique,
description text,
created_at timestamp with time zone default now() not null
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_role_permission;

View File

@ -0,0 +1,10 @@
create table if not exists admin_role_permission
(
role_id integer not null
references admin_role
on delete cascade,
permission_id integer not null
references admin_permission
on delete cascade,
primary key (role_id, permission_id)
);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_admin_user_username;
DROP TABLE IF EXISTS admin_user;

View File

@ -0,0 +1,14 @@
create table if not exists admin_user
(
id serial
primary key,
username varchar(255) not null
unique,
password_hash varchar(255) not null,
is_active boolean default true not null,
created_at timestamp with time zone default now() not null,
updated_at timestamp with time zone default now() not null
);
create index if not exists idx_admin_user_username
on admin_user (username);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS admin_user_role;

View File

@ -0,0 +1,10 @@
create table if not exists admin_user_role
(
user_id integer not null
references admin_user
on delete cascade,
role_id integer not null
references admin_role
on delete cascade,
primary key (user_id, role_id)
);

View File

@ -0,0 +1,8 @@
DROP INDEX IF EXISTS idx_agent_task_retry_count;
DROP INDEX IF EXISTS idx_agent_task_issue;
DROP INDEX IF EXISTS idx_agent_task_created_at;
DROP INDEX IF EXISTS idx_agent_task_created_by;
DROP INDEX IF EXISTS idx_agent_task_status;
DROP INDEX IF EXISTS idx_agent_task_parent;
DROP INDEX IF EXISTS idx_agent_task_project;
DROP TABLE IF EXISTS agent_task;

View File

@ -0,0 +1,45 @@
create table if not exists agent_task
(
id bigserial
primary key,
project_uuid uuid not null,
parent_id bigint
constraint fk_agent_task_parent
references agent_task
on delete set null,
agent_type varchar(20) default 'react'::character varying not null,
status varchar(20) default 'pending'::character varying not null,
title varchar(255),
input text not null,
output text,
error text,
created_by uuid,
created_at timestamp with time zone default now() not null,
updated_at timestamp with time zone default now() not null,
started_at timestamp with time zone,
done_at timestamp with time zone,
progress varchar(255),
issue_id uuid,
retry_count integer default 0
);
create index if not exists idx_agent_task_project
on agent_task (project_uuid);
create index if not exists idx_agent_task_parent
on agent_task (parent_id);
create index if not exists idx_agent_task_status
on agent_task (status);
create index if not exists idx_agent_task_created_by
on agent_task (created_by);
create index if not exists idx_agent_task_created_at
on agent_task (created_at);
create index if not exists idx_agent_task_issue
on agent_task (issue_id);
create index if not exists idx_agent_task_retry_count
on agent_task (retry_count);

View File

@ -0,0 +1,8 @@
DROP INDEX IF EXISTS idx_ai_conv_project_uid;
DROP INDEX IF EXISTS idx_ai_conv_access_vis;
DROP INDEX IF EXISTS idx_ai_conv_project_created;
DROP INDEX IF EXISTS idx_ai_conv_user_created;
DROP INDEX IF EXISTS idx_ai_conv_scope;
DROP INDEX IF EXISTS idx_ai_conv_project_id;
DROP INDEX IF EXISTS idx_ai_conv_user_id;
DROP TABLE IF EXISTS ai_conversation;

View File

@ -0,0 +1,49 @@
create table if not exists ai_conversation
(
id uuid default gen_random_uuid() not null
primary key,
user_id uuid not null,
project_id uuid
constraint fk_ai_conv_project
references project
on delete cascade,
scope varchar(16) not null,
title varchar(512),
model varchar(128) default 'gpt-4'::character varying not null,
model_config jsonb,
status varchar(32) default 'active'::character varying not null,
root_message_id uuid,
fork_count integer default 0 not null,
is_shared boolean default false not null,
message_count integer default 0 not null,
token_usage_total integer,
created_at timestamp with time zone default now() not null,
updated_at timestamp with time zone default now() not null,
access_visibility varchar(32) default 'owner'::character varying not null,
can_ask varchar(32) default 'owner'::character varying not null,
project_uid integer,
model_uid uuid,
model_name varchar(256)
);
create index if not exists idx_ai_conv_user_id
on ai_conversation (user_id);
create index if not exists idx_ai_conv_project_id
on ai_conversation (project_id);
create index if not exists idx_ai_conv_scope
on ai_conversation (scope);
create index if not exists idx_ai_conv_user_created
on ai_conversation (user_id asc, created_at desc);
create index if not exists idx_ai_conv_project_created
on ai_conversation (project_id asc, created_at desc);
create index if not exists idx_ai_conv_access_vis
on ai_conversation (access_visibility);
create index if not exists idx_ai_conv_project_uid
on ai_conversation (project_id, project_uid)
where (project_uid IS NOT NULL);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_ai_msg_parent;
DROP INDEX IF EXISTS idx_ai_msg_conv;
DROP TABLE IF EXISTS ai_message;

View File

@ -0,0 +1,30 @@
create table if not exists ai_message
(
id uuid default gen_random_uuid() not null
primary key,
conversation_id uuid not null
constraint fk_ai_msg_conv
references ai_conversation
on delete cascade,
parent_message_id uuid
constraint fk_ai_msg_parent
references ai_message
on delete set null,
role varchar(16) not null,
content jsonb not null,
model varchar(128),
is_fork_origin boolean default false not null,
stop_reason varchar(32),
input_tokens integer,
output_tokens integer,
latency_ms integer,
metadata jsonb,
room_id uuid,
created_at timestamp with time zone default now() not null
);
create index if not exists idx_ai_msg_conv
on ai_message (conversation_id, created_at);
create index if not exists idx_ai_msg_parent
on ai_message (parent_message_id);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_ai_fork_fork;
DROP INDEX IF EXISTS idx_ai_fork_source;
DROP TABLE IF EXISTS ai_message_fork;

View File

@ -0,0 +1,20 @@
create table if not exists ai_message_fork
(
id uuid default gen_random_uuid() not null
primary key,
source_message_id uuid not null
constraint fk_ai_fork_source
references ai_message
on delete cascade,
fork_message_id uuid not null
constraint fk_ai_fork_fork
references ai_message
on delete cascade,
created_at timestamp with time zone default now() not null
);
create index if not exists idx_ai_fork_source
on ai_message_fork (source_message_id);
create index if not exists idx_ai_fork_fork
on ai_message_fork (fork_message_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_model_provider_id;
DROP TABLE IF EXISTS ai_model;

View File

@ -0,0 +1,19 @@
create table if not exists ai_model
(
id uuid not null
primary key,
provider_id uuid not null,
name varchar(255) not null,
modality varchar(255) not null,
capability varchar(255) not null,
context_length bigint not null,
max_output_tokens bigint,
training_cutoff timestamp with time zone,
is_open_source boolean default false not null,
status varchar(255) not null,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);
create index if not exists idx_ai_model_provider_id
on ai_model (provider_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_model_capability_model_version_id;
DROP TABLE IF EXISTS ai_model_capability;

View File

@ -0,0 +1,12 @@
create table if not exists ai_model_capability
(
id bigserial
primary key,
model_version_id uuid not null,
capability varchar(255) not null,
is_supported boolean default false not null,
created_at timestamp with time zone not null
);
create index if not exists idx_ai_model_capability_model_version_id
on ai_model_capability (model_version_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_model_parameter_profile_model_version_id;
DROP TABLE IF EXISTS ai_model_parameter_profile;

View File

@ -0,0 +1,16 @@
create table if not exists ai_model_parameter_profile
(
id bigserial
primary key,
model_version_id uuid not null
unique,
temperature_min double precision not null,
temperature_max double precision not null,
top_p_min double precision not null,
top_p_max double precision not null,
frequency_penalty_supported boolean default false not null,
presence_penalty_supported boolean default false not null
);
create unique index if not exists idx_ai_model_parameter_profile_model_version_id
on ai_model_parameter_profile (model_version_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_model_pricing_model_version_id;
DROP TABLE IF EXISTS ai_model_pricing;

View File

@ -0,0 +1,13 @@
create table if not exists ai_model_pricing
(
id bigserial
primary key,
model_version_id uuid not null,
input_price_per_1k_tokens varchar(255) not null,
output_price_per_1k_tokens varchar(255) not null,
currency varchar(255) not null,
effective_from timestamp with time zone not null
);
create index if not exists idx_ai_model_pricing_model_version_id
on ai_model_pricing (model_version_id);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_model_provider;

View File

@ -0,0 +1,11 @@
create table if not exists ai_model_provider
(
id uuid not null
primary key,
name varchar(255) not null,
display_name varchar(255) not null,
website varchar(255),
status varchar(255) not null,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_model_version_model_id;
DROP TABLE IF EXISTS ai_model_version;

View File

@ -0,0 +1,15 @@
create table if not exists ai_model_version
(
id uuid not null
primary key,
model_id uuid not null,
version varchar(255) not null,
release_date timestamp with time zone,
change_log text,
is_default boolean default false not null,
status varchar(255) not null,
created_at timestamp with time zone not null
);
create index if not exists idx_ai_model_version_model_id
on ai_model_version (model_id);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_ai_session_room;
DROP TABLE IF EXISTS ai_session;

View File

@ -0,0 +1,19 @@
create table if not exists ai_session
(
id uuid not null
primary key,
room uuid not null,
model uuid not null,
version uuid not null,
token_input bigint default 0 not null,
token_output bigint default 0 not null,
latency_ms bigint,
cost double precision,
currency varchar(255),
error_message text,
error_code varchar(255),
created_at timestamp with time zone not null
);
create index if not exists idx_ai_session_room
on ai_session (room);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_ai_share_token;
DROP INDEX IF EXISTS idx_ai_share_conv;
DROP TABLE IF EXISTS ai_shared_conversation;

View File

@ -0,0 +1,21 @@
create table if not exists ai_shared_conversation
(
id uuid default gen_random_uuid() not null
primary key,
conversation_id uuid not null
constraint fk_ai_share_conv
references ai_conversation
on delete cascade,
share_token varchar(128) not null
unique,
created_by uuid not null,
view_count integer default 0 not null,
created_at timestamp with time zone default now() not null,
expires_at timestamp with time zone
);
create index if not exists idx_ai_share_conv
on ai_shared_conversation (conversation_id);
create index if not exists idx_ai_share_token
on ai_shared_conversation (share_token);

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_ai_subagent_session_message;
DROP INDEX IF EXISTS idx_ai_subagent_session_children;
DROP INDEX IF EXISTS idx_ai_subagent_session_conv;
DROP TABLE IF EXISTS ai_subagent_session;

View File

@ -1,20 +1,3 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"ai_subagent_session"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
create table if not exists ai_subagent_session
(
id uuid not null primary key,
@ -40,24 +23,3 @@ create index if not exists idx_ai_subagent_session_children
create index if not exists idx_ai_subagent_session_message
on ai_subagent_session (message_id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
r#"
drop index if exists idx_ai_subagent_session_message;
drop index if exists idx_ai_subagent_session_children;
drop index if exists idx_ai_subagent_session_conv;
drop table if exists ai_subagent_session;
"#,
)
.await?;
Ok(())
}
}

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_ai_token_recorded;
DROP INDEX IF EXISTS idx_ai_token_conv;
DROP INDEX IF EXISTS idx_ai_token_user;
DROP TABLE IF EXISTS ai_token_usage;

View File

@ -0,0 +1,21 @@
create table if not exists ai_token_usage
(
id uuid default gen_random_uuid() not null
primary key,
user_id uuid not null,
conversation_id uuid,
model varchar(128) not null,
input_tokens integer not null,
output_tokens integer not null,
cost_usd numeric(10, 6),
recorded_at timestamp with time zone default now() not null
);
create index if not exists idx_ai_token_user
on ai_token_usage (user_id);
create index if not exists idx_ai_token_conv
on ai_token_usage (conversation_id);
create index if not exists idx_ai_token_recorded
on ai_token_usage (recorded_at);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_tool_auth;

View File

@ -0,0 +1,17 @@
create table if not exists ai_tool_auth
(
session uuid not null,
tool_call_id varchar(255) not null,
method varchar(255) not null,
arguments text not null,
decision boolean default false not null,
reason varchar(255) not null,
decision_by uuid not null,
decision_comment text,
logs jsonb not null,
expires_at timestamp with time zone,
authorized_at timestamp with time zone,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null,
primary key (session, tool_call_id)
);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_ai_tool_call_status;
DROP INDEX IF EXISTS idx_ai_tool_call_session;
DROP TABLE IF EXISTS ai_tool_call;

View File

@ -0,0 +1,24 @@
create table if not exists ai_tool_call
(
tool_call_id varchar(255) not null,
session uuid not null,
tool_name varchar(255) not null,
caller uuid not null,
arguments jsonb not null,
result jsonb not null,
status varchar(255) not null,
execution_time_ms bigint,
error_message text,
error_stack text,
retry_count integer default 0 not null,
created_at timestamp with time zone not null,
completed_at timestamp with time zone,
updated_at timestamp with time zone not null,
primary key (tool_call_id, session)
);
create index if not exists idx_ai_tool_call_session
on ai_tool_call (session);
create index if not exists idx_ai_tool_call_status
on ai_tool_call (status);

View File

@ -0,0 +1,3 @@
DROP TRIGGER IF EXISTS room_message_tsv_update ON room_message;
DROP FUNCTION IF EXISTS room_message_tsv_trigger();
DROP TYPE IF EXISTS notification_type;

View File

@ -0,0 +1,33 @@
CREATE OR REPLACE FUNCTION room_message_tsv_trigger()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
NEW.content_tsv := to_tsvector('english', NEW.content);
RETURN NEW;
END;
$function$;
CREATE OR REPLACE TRIGGER room_message_tsv_update
BEFORE INSERT OR UPDATE
ON room_message
FOR EACH ROW
EXECUTE PROCEDURE room_message_tsv_trigger();
DO
$$
BEGIN
IF NOT EXISTS (SELECT 1
FROM pg_type
WHERE typname = 'notification_type'
AND typtype = 'e') THEN
CREATE TYPE notification_type AS ENUM (
'mention',
'invitation',
'role_change',
'room_created',
'room_deleted',
'system_announcement'
);
END IF;
END
$$;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_issue_state;
DROP INDEX IF EXISTS idx_issue_author;
DROP INDEX IF EXISTS idx_issue_project;
DROP TABLE IF EXISTS issue;

View File

@ -0,0 +1,25 @@
create table if not exists issue
(
id uuid not null
primary key,
project uuid not null,
number bigint not null,
title varchar(255) not null,
body text,
state varchar(255) not null,
author uuid not null,
milestone varchar(255),
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null,
closed_at timestamp with time zone,
created_by_ai boolean default false not null
);
create index if not exists idx_issue_project
on issue (project);
create index if not exists idx_issue_author
on issue (author);
create index if not exists idx_issue_state
on issue (state);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_assignee;

View File

@ -0,0 +1,7 @@
create table if not exists issue_assignee
(
issue uuid not null,
"user" uuid not null,
assigned_at timestamp with time zone not null,
primary key (issue, "user")
);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_issue_comment_issue;
DROP TABLE IF EXISTS issue_comment;

View File

@ -0,0 +1,13 @@
create table if not exists issue_comment
(
id bigserial
primary key,
issue uuid not null,
author uuid not null,
body text not null,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);
create index if not exists idx_issue_comment_issue
on issue_comment (issue);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_comment_reaction;

View File

@ -0,0 +1,8 @@
create table if not exists issue_comment_reaction
(
comment_id bigint not null,
user_uuid uuid not null,
reaction varchar(255) not null,
created_at timestamp with time zone not null,
primary key (comment_id, user_uuid, reaction)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_label;

View File

@ -0,0 +1,7 @@
create table if not exists issue_label
(
issue uuid not null,
label bigint not null,
relation_at timestamp with time zone not null,
primary key (issue, label)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_pull_request;

View File

@ -0,0 +1,8 @@
create table if not exists issue_pull_request
(
issue uuid not null,
repo uuid not null,
number bigint not null,
relation_at timestamp with time zone not null,
primary key (issue, repo, number)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_reaction;

View File

@ -0,0 +1,8 @@
create table if not exists issue_reaction
(
issue_uuid uuid not null,
user_uuid uuid not null,
reaction varchar(255) not null,
created_at timestamp with time zone not null,
primary key (issue_uuid, user_uuid, reaction)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_repo;

View File

@ -0,0 +1,7 @@
create table if not exists issue_repo
(
issue uuid not null,
repo uuid not null,
relation_at timestamp with time zone not null,
primary key (issue, repo)
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS issue_subscriber;

View File

@ -0,0 +1,8 @@
create table if not exists issue_subscriber
(
issue uuid not null,
"user" uuid not null,
subscribed boolean default true not null,
created_at timestamp with time zone not null,
primary key (issue, "user")
);

View File

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_label_project;
DROP TABLE IF EXISTS label;

View File

@ -0,0 +1,11 @@
create table if not exists label
(
id bigserial
primary key,
project_uuid uuid not null,
name varchar(255) not null,
color varchar(255) not null
);
create index if not exists idx_label_project
on label (project_uuid);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_notify_created_at;
DROP INDEX IF EXISTS idx_notify_user;
DROP TABLE IF EXISTS notify;

View File

@ -0,0 +1,20 @@
create table if not exists notify
(
id bigserial
primary key,
user_uuid uuid not null,
title varchar(255) not null,
description text,
content text not null,
url varchar(255),
kind integer not null,
read_at timestamp with time zone,
deleted_at timestamp with time zone,
created_at timestamp with time zone not null
);
create index if not exists idx_notify_user
on notify (user_uuid);
create index if not exists idx_notify_created_at
on notify (created_at);

View File

@ -0,0 +1,6 @@
DROP INDEX IF EXISTS idx_workspace_deleted_at;
DROP INDEX IF EXISTS idx_workspace_slug;
DROP INDEX IF EXISTS idx_project_workspace_id;
DROP INDEX IF EXISTS idx_project_created_by;
DROP INDEX IF EXISTS idx_project_name;
DROP TABLE IF EXISTS project;

View File

@ -0,0 +1,32 @@
create table if not exists project
(
id uuid not null
primary key,
name varchar(255) not null,
display_name varchar(255) not null,
avatar_url varchar(255),
description text,
is_public boolean default false not null,
created_by uuid not null,
created_at timestamp with time zone not null,
updated_at timestamp with time zone not null,
workspace_id uuid
references workspace
on delete set null
);
create index if not exists idx_project_name
on project (name);
create index if not exists idx_project_created_by
on project (created_by);
create index if not exists idx_project_workspace_id
on project (workspace_id)
where (workspace_id IS NOT NULL);
create unique index if not exists idx_workspace_slug
on workspace (slug);
create index if not exists idx_workspace_deleted_at
on workspace (deleted_at);

View File

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_project_access_log_created_at;
DROP INDEX IF EXISTS idx_project_access_log_project;
DROP TABLE IF EXISTS project_access_log;

View File

@ -0,0 +1,17 @@
create table if not exists project_access_log
(
id bigserial
primary key,
project uuid not null,
actor_uid uuid,
action varchar(255) not null,
ip_address varchar(255),
user_agent varchar(255),
created_at timestamp with time zone not null
);
create index if not exists idx_project_access_log_project
on project_access_log (project);
create index if not exists idx_project_access_log_created_at
on project_access_log (created_at);

View File

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_project_activity_event_type;
DROP INDEX IF EXISTS idx_project_activity_created_at;
DROP INDEX IF EXISTS idx_project_activity_project;
DROP TABLE IF EXISTS project_activity;

View File

@ -0,0 +1,25 @@
create table if not exists project_activity
(
id bigserial
primary key,
project uuid not null,
repo uuid,
actor uuid not null,
event_type varchar(50) not null,
event_id uuid,
event_sub_id bigint,
title varchar(500) not null,
content text,
metadata jsonb,
is_private boolean default false not null,
created_at timestamp with time zone not null
);
create index if not exists idx_project_activity_project
on project_activity (project);
create index if not exists idx_project_activity_created_at
on project_activity (created_at desc);
create index if not exists idx_project_activity_event_type
on project_activity (event_type);

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