Skip to content
Clone

feat(sagent): add agent collection domain

ZhenYi committed
parent 156e960 commit 2cfa36b Browse files

19 files changed

+731 −35

File tree 19
Modified lib/sagent/src/gateway/mod.rs View lib/sagent/src/gateway/mod.rs
@@ -65,7 +65,9 @@
6565 ) -> Result<T, SagentError> {
6666 match tokio::time::timeout(self.timeout, future).await {
6767 Ok(result) => result.map_err(SagentError::from),
68 Err(_) => Err(SagentError::Unavailable("agent gateway request timed out".into())),
68 Err(_) => Err(SagentError::Unavailable(
69 "agent gateway request timed out".into(),
70 )),
6971 }
7072 }
7173
@@ -76,13 +78,17 @@
7678 metadata: Option<serde_json::Value>,
7779 ) -> Result<SessionDto, SagentError> {
7880 if let Some(id) = existing {
79 match self.timed(self.admin.get_session(SessionId::from_uuid(id))).await {
81 match self
82 .timed(self.admin.get_session(SessionId::from_uuid(id)))
83 .await
84 {
8085 Ok(session) => return Ok(session),
8186 Err(SagentError::NotFound) => {}
8287 Err(error) => return Err(error),
8388 }
8489 }
85 self.timed(self.admin.create_session(None, Some(title), metadata)).await
90 self.timed(self.admin.create_session(None, Some(title), metadata))
91 .await
8692 }
8793
8894 pub async fn list_session_messages(
@@ -91,7 +97,11 @@
9197 limit: Option<u32>,
9298 cursor: Option<u64>,
9399 ) -> Result<Page<MessageDto>, SagentError> {
94 self.timed(self.admin.list_messages(SessionId::from_uuid(session_id), limit, cursor)).await
100 self.timed(
101 self.admin
102 .list_messages(SessionId::from_uuid(session_id), limit, cursor),
103 )
104 .await
95105 }
96106
97107 pub async fn open_session(
@@ -113,7 +123,12 @@
113123 let expires_in = token.expires_in_secs.min(i64::MAX as u64) as i64;
114124 let expires_at = OffsetDateTime::now_utc() + time::Duration::seconds(expires_in);
115125 let client = Client::new(&self.base, token.token.to_string(), session)?;
116 Ok(SessionHandle { client, session, token, expires_at })
126 Ok(SessionHandle {
127 client,
128 session,
129 token,
130 expires_at,
131 })
117132 }
118133}
119134
@@ -158,7 +173,10 @@
158173 limit: Option<u32>,
159174 cursor: Option<u64>,
160175 ) -> Result<Page<MessageDto>, SagentError> {
161 self.client.list_messages(limit, cursor).await.map_err(SagentError::from)
176 self.client
177 .list_messages(limit, cursor)
178 .await
179 .map_err(SagentError::from)
162180 }
163181
164182 pub async fn send_message(
@@ -166,19 +184,34 @@
166184 text: impl Into<SmolStr>,
167185 idempotency_key: Option<SmolStr>,
168186 ) -> Result<MessageAccepted, SagentError> {
169 self.client.send_message(text, idempotency_key).await.map_err(SagentError::from)
187 self.client
188 .send_message(text, idempotency_key)
189 .await
190 .map_err(SagentError::from)
170191 }
171192
172193 pub async fn get_run(&self, run_id: Uuid) -> Result<RunDto, SagentError> {
173 self.client.get_run(RunId::from_uuid(run_id)).await.map_err(SagentError::from)
194 self.client
195 .get_run(RunId::from_uuid(run_id))
196 .await
197 .map_err(SagentError::from)
174198 }
175199
176200 pub async fn cancel_run(&self, run_id: Uuid) -> Result<RunDto, SagentError> {
177 self.client.cancel_run(RunId::from_uuid(run_id)).await.map_err(SagentError::from)
201 self.client
202 .cancel_run(RunId::from_uuid(run_id))
203 .await
204 .map_err(SagentError::from)
178205 }
179206
180 pub async fn stream_events(&self, last_event_id: Option<u64>) -> Result<EventStream, SagentError> {
181 self.client.stream_events(last_event_id).await.map_err(SagentError::from)
207 pub async fn stream_events(
208 &self,
209 last_event_id: Option<u64>,
210 ) -> Result<EventStream, SagentError> {
211 self.client
212 .stream_events(last_event_id)
213 .await
214 .map_err(SagentError::from)
182215 }
183216}
184217
@@ -190,7 +223,9 @@
190223 SdkError::NotFound(_) | SdkError::Gone(_) => Self::NotFound,
191224 SdkError::Conflict(message) => Self::Conflict(message),
192225 SdkError::BadRequest(message) => Self::BadRequest(message),
193 SdkError::Unauthorized(message) | SdkError::Forbidden(message) => Self::Upstream(message),
226 SdkError::Unauthorized(message) | SdkError::Forbidden(message) => {
227 Self::Upstream(message)
228 }
194229 SdkError::Invalid(message) => Self::Internal(message),
195230 SdkError::Server { status, message } => {
196231 if status >= 500 {
@@ -201,4 +236,4 @@
201236 }
202237 }
203238 }
204}
No newline at end of file
239}
Added lib/sagent/src/handlers/collections/create_collection.rs View lib/sagent/src/handlers/collections/create_collection.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use space::services::spaces::SpaceNamePath;
6
7use crate::errors::SagentApiError;
8use crate::services::collections::{CollectionDto, CreateCollectionCmd};
9use crate::services::service::SagentService;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections",
14 tag = "sagent",
15 description = "在空间目录树中创建智能体集,集合与目录节点同时落库。",
16 params(SpaceNamePath),
17 request_body = CreateCollectionCmd,
18 responses(
19 (status = 200, description = "create collection", body = AppResponseData<CollectionDto>),
20 (status = 400, description = "bad request", body = AppResponseData<NoData>),
21 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
22 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
23 (status = 404, description = "not found", body = AppResponseData<NoData>)
24 )
25)]
26pub async fn create_collection(
27 State(service): State<SagentService>,
28 session: Session,
29 Path(path): Path<SpaceNamePath>,
30 Json(cmd): Json<CreateCollectionCmd>,
31) -> Result<AppResponseData<CollectionDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .create_collection(uid, &path.space_name, cmd)
35 .await?;
36 Ok(AppResponseData::ok(dto))
37}
Added lib/sagent/src/handlers/collections/delete_collection.rs View lib/sagent/src/handlers/collections/delete_collection.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::collections::CollectionPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}",
13 tag = "sagent",
14 description = "删除智能体集并级联清除其任务、消息、关注与活动流,同时移除目录节点。",
15 params(CollectionPath),
16 responses(
17 (status = 200, description = "delete collection", body = AppResponseData<NoData>),
18 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn delete_collection(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<CollectionPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .delete_collection(uid, &path.space_name, path.collection_id)
31 .await?;
32 Ok(AppResponseData::ok(NoData))
33}
Added lib/sagent/src/handlers/collections/get_collection.rs View lib/sagent/src/handlers/collections/get_collection.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::collections::{CollectionDto, CollectionPath};
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 get,
12 path = "/agents/{space_name}/collections/{collection_id}",
13 tag = "sagent",
14 description = "读取智能体集详情与任务计数。",
15 params(CollectionPath),
16 responses(
17 (status = 200, description = "get collection", body = AppResponseData<CollectionDto>),
18 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn get_collection(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<CollectionPath>,
27) -> Result<AppResponseData<CollectionDto>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 let dto = service
30 .get_collection(uid, &path.space_name, path.collection_id)
31 .await?;
32 Ok(AppResponseData::ok(dto))
33}
Added lib/sagent/src/handlers/collections/list_collections.rs View lib/sagent/src/handlers/collections/list_collections.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5use space::services::spaces::SpaceNamePath;
6
7use crate::errors::SagentApiError;
8use crate::services::collections::{CollectionPage, ListCollectionQuery};
9use crate::services::service::SagentService;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections",
14 tag = "sagent",
15 description = "分页列出空间内的智能体集,可按目录节点或关键字过滤。",
16 params(SpaceNamePath, ListCollectionQuery),
17 responses(
18 (status = 200, description = "list collections", body = AppResponseData<CollectionPage>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn list_collections(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Query(query): Query<ListCollectionQuery>,
29) -> Result<AppResponseData<CollectionPage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_collections(uid, &path.space_name, query)
33 .await?;
34 Ok(AppResponseData::ok(page))
35}
Added lib/sagent/src/handlers/collections/mod.rs View lib/sagent/src/handlers/collections/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_collection;
2pub mod delete_collection;
3pub mod get_collection;
4pub mod list_collections;
5pub mod update_collection;
Added lib/sagent/src/handlers/collections/update_collection.rs View lib/sagent/src/handlers/collections/update_collection.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::collections::{CollectionDto, CollectionPath, UpdateCollectionCmd};
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 patch,
12 path = "/agents/{space_name}/collections/{collection_id}",
13 tag = "sagent",
14 description = "更新智能体集名称、图标或描述,并同步目录节点元数据。",
15 params(CollectionPath),
16 request_body = UpdateCollectionCmd,
17 responses(
18 (status = 200, description = "update collection", body = AppResponseData<CollectionDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 401, description = "authentication required", body = AppResponseData<NoData>),
21 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
22 (status = 404, description = "not found", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn update_collection(
26 State(service): State<SagentService>,
27 session: Session,
28 Path(path): Path<CollectionPath>,
29 Json(cmd): Json<UpdateCollectionCmd>,
30) -> Result<AppResponseData<CollectionDto>, SagentApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .update_collection(uid, &path.space_name, path.collection_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Modified lib/sagent/src/handlers/mod.rs View lib/sagent/src/handlers/mod.rs
@@ -0,0 +1 @@
1pub mod collections;
Added lib/sagent/src/services/collections.rs View lib/sagent/src/services/collections.rs
@@ -0,0 +1,436 @@
1use std::collections::HashMap;
2
3use models::dir::dirs::{self, DirKind};
4use models::sagent::agent::AgentState;
5use models::sagent::{agent, agent_event_line, agent_message, agent_watch, agents};
6use sea_orm::sea_query::LockType;
7use sea_orm::{
8 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
9 QuerySelect, Set, TransactionTrait, Unchanged,
10};
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13use utoipa::{IntoParams, ToSchema};
14use uuid::Uuid;
15
16use crate::errors::SagentError;
17use crate::services::service::SagentService;
18use crate::share::authz::require_collection_manage;
19use crate::share::lookup::{lookup_collection, space_context};
20
21pub const ACTIVE_STATES: [AgentState; 4] = [
22 AgentState::Pending,
23 AgentState::Planning,
24 AgentState::Executing,
25 AgentState::Waiting,
26];
27
28#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
29pub struct CollectionDto {
30 pub id: Uuid,
31 pub dir_id: Uuid,
32 pub name: String,
33 pub icon: Option<String>,
34 pub description: Option<String>,
35 pub total_tasks: i64,
36 pub running_tasks: i64,
37 pub created_at: OffsetDateTime,
38 pub updated_at: Option<OffsetDateTime>,
39}
40
41pub type CollectionPage = export::pagination::Page<CollectionDto>;
42
43#[derive(Debug, Clone, Deserialize, ToSchema)]
44pub struct CreateCollectionCmd {
45 pub name: String,
46 #[serde(default, alias = "dir_id")]
47 pub parent_dir_id: Option<Uuid>,
48 #[serde(default)]
49 pub icon: Option<String>,
50 #[serde(default)]
51 pub description: Option<String>,
52}
53
54#[derive(Debug, Clone, Deserialize, ToSchema)]
55pub struct UpdateCollectionCmd {
56 #[serde(default)]
57 pub name: Option<String>,
58 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
59 pub icon: Option<Option<String>>,
60 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
61 pub description: Option<Option<String>>,
62}
63
64#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
65pub struct ListCollectionQuery {
66 pub dir_id: Option<Uuid>,
67 pub q: Option<String>,
68 pub limit: Option<u64>,
69 pub offset: Option<u64>,
70}
71
72#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
73pub struct CollectionPath {
74 pub space_name: String,
75 pub collection_id: Uuid,
76}
77
78impl SagentService {
79 fn collection_dto(
80 collection: agents::Model,
81 total_tasks: i64,
82 running_tasks: i64,
83 ) -> CollectionDto {
84 CollectionDto {
85 id: collection.id,
86 dir_id: collection.dir_id,
87 name: collection.name,
88 icon: collection.icon,
89 description: collection.description,
90 total_tasks,
91 running_tasks,
92 created_at: collection.created_at,
93 updated_at: collection.updated_at,
94 }
95 }
96
97 async fn task_counts_by_collection(
98 &self,
99 collection_ids: &[Uuid],
100 ) -> Result<(HashMap<Uuid, i64>, HashMap<Uuid, i64>), SagentError> {
101 let mut total_counts = HashMap::new();
102 let mut running_counts = HashMap::new();
103 if collection_ids.is_empty() {
104 return Ok((total_counts, running_counts));
105 }
106
107 let total_rows: Vec<(Uuid, i64)> = agent::Entity::find()
108 .select_only()
109 .column(agent::Column::CollectionId)
110 .column_as(agent::Column::CollectionId.count(), "cnt")
111 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
112 .group_by(agent::Column::CollectionId)
113 .into_tuple()
114 .all(self.db().read())
115 .await?;
116 for (collection_id, cnt) in total_rows {
117 total_counts.insert(collection_id, cnt);
118 }
119
120 let running_rows: Vec<(Uuid, i64)> = agent::Entity::find()
121 .select_only()
122 .column(agent::Column::CollectionId)
123 .column_as(agent::Column::CollectionId.count(), "cnt")
124 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
125 .filter(agent::Column::State.is_in(ACTIVE_STATES))
126 .group_by(agent::Column::CollectionId)
127 .into_tuple()
128 .all(self.db().read())
129 .await?;
130 for (collection_id, cnt) in running_rows {
131 running_counts.insert(collection_id, cnt);
132 }
133
134 Ok((total_counts, running_counts))
135 }
136
137 async fn build_collection_dto(
138 &self,
139 collection: agents::Model,
140 ) -> Result<CollectionDto, SagentError> {
141 let (total_counts, running_counts) =
142 self.task_counts_by_collection(&[collection.id]).await?;
143 let total = total_counts.get(&collection.id).copied().unwrap_or(0);
144 let running = running_counts.get(&collection.id).copied().unwrap_or(0);
145 Ok(Self::collection_dto(collection, total, running))
146 }
147
148 pub async fn create_collection(
149 &self,
150 caller: Uuid,
151 space_name: &str,
152 cmd: CreateCollectionCmd,
153 ) -> Result<CollectionDto, SagentError> {
154 let space = space_context(self, caller, space_name).await?;
155 require_collection_manage(self, &space, caller).await?;
156
157 let name = cmd.name.trim();
158 if name.is_empty() || name.chars().count() > self.config().sagent.max_collection_name_len {
159 return Err(SagentError::BadRequest(
160 "collection name is empty or too long".to_string(),
161 ));
162 }
163
164 if let Some(parent_id) = cmd.parent_dir_id {
165 let parent = dirs::Entity::find_by_id(parent_id)
166 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
167 .one(self.db().read())
168 .await?
169 .ok_or(SagentError::NotFound)?;
170 if parent.kind != DirKind::Dir {
171 return Err(SagentError::BadRequest(
172 "parent node must be a dir".to_string(),
173 ));
174 }
175 }
176
177 let collection = {
178 let txn = self.db().write().begin().await?;
179 let max_node = dirs::Entity::find()
180 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
181 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
182 .order_by_desc(dirs::Column::Position)
183 .lock(LockType::Update)
184 .one(&txn)
185 .await?;
186 let position = max_node.map(|node| node.position + 1).unwrap_or(0);
187 let now = OffsetDateTime::now_utc();
188
189 let dir = dirs::ActiveModel {
190 id: Set(Uuid::now_v7()),
191 space_id: Set(Some(space.id)),
192 name: Set(name.to_string()),
193 kind: Set(DirKind::Object),
194 parent_dir_id: Set(cmd.parent_dir_id),
195 color: Set(None),
196 position: Set(position),
197 icon: Set(cmd.icon.clone()),
198 topic: Set(None),
199 purpose: Set(cmd.description.clone()),
200 is_archived: Set(false),
201 archived_at: Set(None),
202 archived_by: Set(None),
203 created_at: Set(now),
204 updated_at: Set(now),
205 created_by_uid: Set(caller),
206 }
207 .insert(&txn)
208 .await?;
209
210 let row = agents::ActiveModel {
211 id: Set(Uuid::now_v7()),
212 dir_id: Set(dir.id),
213 name: Set(name.to_string()),
214 icon: Set(cmd.icon),
215 description: Set(cmd.description),
216 config: Set(None),
217 created_at: Set(now),
218 updated_at: Set(None),
219 }
220 .insert(&txn)
221 .await?;
222
223 txn.commit().await?;
224 row
225 };
226
227 Ok(Self::collection_dto(collection, 0, 0))
228 }
229
230 pub async fn list_collections(
231 &self,
232 caller: Uuid,
233 space_name: &str,
234 query: ListCollectionQuery,
235 ) -> Result<CollectionPage, SagentError> {
236 let space = space_context(self, caller, space_name).await?;
237 let (limit, offset) = self.paginate(query.limit, query.offset);
238
239 let dirs_in_space = dirs::Entity::find()
240 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
241 .filter(dirs::Column::Kind.eq(DirKind::Object))
242 .all(self.db().read())
243 .await?;
244
245 if dirs_in_space.is_empty() {
246 return Ok(CollectionPage {
247 items: Vec::new(),
248 total: 0,
249 limit,
250 offset,
251 });
252 }
253
254 let dir_ids: Vec<Uuid> = if let Some(target_dir) = query.dir_id {
255 dirs_in_space
256 .into_iter()
257 .filter(|dir| dir.parent_dir_id == Some(target_dir) || dir.id == target_dir)
258 .map(|dir| dir.id)
259 .collect()
260 } else {
261 dirs_in_space.into_iter().map(|dir| dir.id).collect()
262 };
263
264 if dir_ids.is_empty() {
265 return Ok(CollectionPage {
266 items: Vec::new(),
267 total: 0,
268 limit,
269 offset,
270 });
271 }
272
273 let mut select = agents::Entity::find().filter(agents::Column::DirId.is_in(dir_ids));
274 if let Some(search) = query.q {
275 let keyword = search.trim();
276 if !keyword.is_empty() {
277 select = select.filter(agents::Column::Name.contains(keyword));
278 }
279 }
280 select = select.order_by_desc(agents::Column::CreatedAt);
281
282 let total = select.clone().count(self.db().read()).await? as i64;
283 let rows = select
284 .limit(limit)
285 .offset(offset)
286 .all(self.db().read())
287 .await?;
288
289 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
290 let (total_counts, running_counts) = self.task_counts_by_collection(&ids).await?;
291 let items = rows
292 .into_iter()
293 .map(|row| {
294 let total = total_counts.get(&row.id).copied().unwrap_or(0);
295 let running = running_counts.get(&row.id).copied().unwrap_or(0);
296 Self::collection_dto(row, total, running)
297 })
298 .collect();
299
300 Ok(CollectionPage {
301 items,
302 total,
303 limit,
304 offset,
305 })
306 }
307
308 pub async fn get_collection(
309 &self,
310 caller: Uuid,
311 space_name: &str,
312 collection_id: Uuid,
313 ) -> Result<CollectionDto, SagentError> {
314 let space = space_context(self, caller, space_name).await?;
315 let collection = lookup_collection(self, &space, collection_id, caller).await?;
316 self.build_collection_dto(collection).await
317 }
318
319 pub async fn update_collection(
320 &self,
321 caller: Uuid,
322 space_name: &str,
323 collection_id: Uuid,
324 cmd: UpdateCollectionCmd,
325 ) -> Result<CollectionDto, SagentError> {
326 let space = space_context(self, caller, space_name).await?;
327 require_collection_manage(self, &space, caller).await?;
328 let collection = lookup_collection(self, &space, collection_id, caller).await?;
329
330 let now = OffsetDateTime::now_utc();
331 let mut collection_am: agents::ActiveModel = collection.clone().into();
332 let mut dir_am = dirs::ActiveModel {
333 id: Unchanged(collection.dir_id),
334 ..Default::default()
335 };
336 let mut dir_changed = false;
337
338 if let Some(new_name) = cmd.name {
339 let name = new_name.trim();
340 if name.is_empty()
341 || name.chars().count() > self.config().sagent.max_collection_name_len
342 {
343 return Err(SagentError::BadRequest(
344 "collection name is empty or too long".to_string(),
345 ));
346 }
347 collection_am.name = Set(name.to_string());
348 dir_am.name = Set(name.to_string());
349 dir_changed = true;
350 }
351 if let Some(icon) = cmd.icon {
352 collection_am.icon = Set(icon.clone());
353 dir_am.icon = Set(icon);
354 dir_changed = true;
355 }
356 if let Some(description) = cmd.description {
357 collection_am.description = Set(description.clone());
358 dir_am.purpose = Set(description);
359 dir_changed = true;
360 }
361
362 collection_am.updated_at = Set(Some(now));
363
364 let updated = {
365 let txn = self.db().write().begin().await?;
366 if dir_changed {
367 dir_am.updated_at = Set(now);
368 dir_am.update(&txn).await?;
369 }
370 let updated = collection_am.update(&txn).await?;
371 txn.commit().await?;
372 updated
373 };
374
375 self.build_collection_dto(updated).await
376 }
377
378 pub async fn delete_collection(
379 &self,
380 caller: Uuid,
381 space_name: &str,
382 collection_id: Uuid,
383 ) -> Result<(), SagentError> {
384 let space = space_context(self, caller, space_name).await?;
385 require_collection_manage(self, &space, caller).await?;
386 let collection = lookup_collection(self, &space, collection_id, caller).await?;
387
388 self.db()
389 .primary()
390 .transaction::<_, (), SagentError>(|txn| {
391 Box::pin(async move {
392 let task_ids: Vec<Uuid> = agent::Entity::find()
393 .filter(agent::Column::CollectionId.eq(collection.id))
394 .all(txn)
395 .await?
396 .into_iter()
397 .map(|row| row.id)
398 .collect();
399
400 if !task_ids.is_empty() {
401 agent_message::Entity::delete_many()
402 .filter(agent_message::Column::AgentId.is_in(task_ids.clone()))
403 .exec(txn)
404 .await?;
405 agent_watch::Entity::delete_many()
406 .filter(agent_watch::Column::AgentId.is_in(task_ids.clone()))
407 .exec(txn)
408 .await?;
409 agent_event_line::Entity::delete_many()
410 .filter(agent_event_line::Column::AgentId.is_in(task_ids))
411 .exec(txn)
412 .await?;
413 }
414
415 agent_event_line::Entity::delete_many()
416 .filter(agent_event_line::Column::CollectionId.eq(collection.id))
417 .exec(txn)
418 .await?;
419 agent::Entity::delete_many()
420 .filter(agent::Column::CollectionId.eq(collection.id))
421 .exec(txn)
422 .await?;
423 agents::Entity::delete_by_id(collection.id)
424 .exec(txn)
425 .await?;
426 dirs::Entity::delete_by_id(collection.dir_id)
427 .exec(txn)
428 .await?;
429 Ok(())
430 })
431 })
432 .await?;
433
434 Ok(())
435 }
436}
Modified lib/sagent/src/services/mod.rs View lib/sagent/src/services/mod.rs
@@ -1 +1,2 @@
1pub mod service;
No newline at end of file
1pub mod collections;
2pub mod service;
Modified lib/sagent/src/services/service.rs View lib/sagent/src/services/service.rs
@@ -23,7 +23,10 @@
2323
2424impl SagentService {
2525 pub fn new(state: AppState) -> Self {
26 Self { state, gateway: Arc::new(OnceLock::new()) }
26 Self {
27 state,
28 gateway: Arc::new(OnceLock::new()),
29 }
2730 }
2831
2932 pub fn config(&self) -> &AppConfig {
@@ -115,4 +118,4 @@
115118 ) -> Result<Option<SessionWithUser>, SessionError> {
116119 session::lookup_session_with_user(self.db().read(), token_hash).await
117120 }
118}
No newline at end of file
121}
Modified lib/sagent/src/share/authz.rs View lib/sagent/src/share/authz.rs
@@ -29,4 +29,4 @@
2929 .await
3030 .map_err(SagentError::from)?;
3131 Ok(perms.has(MANAGE_CHANNELS))
32}
No newline at end of file
32}
Modified lib/sagent/src/share/guard.rs View lib/sagent/src/share/guard.rs
@@ -13,4 +13,4 @@
1313 next: Next,
1414) -> Response {
1515 session_guard::<SagentService>(State(service), session, request, next).await
16}
No newline at end of file
16}
Modified lib/sagent/src/share/lookup.rs View lib/sagent/src/share/lookup.rs
@@ -102,9 +102,13 @@
102102) -> Result<agent::Model, SagentError> {
103103 let base = agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
104104 let found = if let Ok(id) = Uuid::parse_str(task_ref) {
105 base.filter(agent::Column::Id.eq(id)).one(service.db().read()).await?
105 base.filter(agent::Column::Id.eq(id))
106 .one(service.db().read())
107 .await?
106108 } else if let Ok(number) = task_ref.parse::<i32>() {
107 base.filter(agent::Column::AutoincrementId.eq(number)).one(service.db().read()).await?
109 base.filter(agent::Column::AutoincrementId.eq(number))
110 .one(service.db().read())
111 .await?
108112 } else {
109113 None
110114 };
@@ -129,7 +133,8 @@
129133 collection_id: Uuid,
130134 task_ref: &str,
131135) -> Result<(spaces::Model, agents::Model, agent::Model), SagentError> {
132 let (space, collection) = collection_context(service, caller, space_name, collection_id).await?;
136 let (space, collection) =
137 collection_context(service, caller, space_name, collection_id).await?;
133138 let task = lookup_task_by_ref(service, &collection, task_ref).await?;
134139 Ok((space, collection, task))
135}
No newline at end of file
140}
Modified lib/sagent/src/share/mod.rs View lib/sagent/src/share/mod.rs
@@ -1,3 +1,3 @@
11pub mod authz;
22pub mod guard;
3pub mod lookup;
No newline at end of file
3pub mod lookup;
Modified lib/sagent/src/errors.rs View lib/sagent/src/errors.rs
@@ -66,10 +66,9 @@
6666 ),
6767 SagentError::Unavailable(msg) => Self::build(
6868 503,
69 export::rest::AppResponseData::err(503, msg)
70 .with_error_code("gateway_unavailable"),
69 export::rest::AppResponseData::err(503, msg).with_error_code("gateway_unavailable"),
7170 ),
7271 SagentError::Internal(message) => Self::internal(message),
7372 }
7473 }
75}
No newline at end of file
74}
Modified lib/sagent/src/lib.rs View lib/sagent/src/lib.rs
@@ -4,4 +4,4 @@
44pub mod openapi;
55pub mod routes;
66pub mod services;
7pub mod share;
No newline at end of file
7pub mod share;
Modified lib/sagent/src/openapi.rs View lib/sagent/src/openapi.rs
@@ -3,10 +3,30 @@
33
44#[derive(OpenApi)]
55#[openapi(
6 paths(),
7 components(schemas(AppResponseData<export::rest::NoData>, export::rest::NoData)),
6 paths(
7 crate::handlers::collections::create_collection::create_collection,
8 crate::handlers::collections::list_collections::list_collections,
9 crate::handlers::collections::get_collection::get_collection,
10 crate::handlers::collections::update_collection::update_collection,
11 crate::handlers::collections::delete_collection::delete_collection,
12 ),
13 components(schemas(
14 AppResponseData<export::rest::NoData>,
15 AppResponseData<crate::services::collections::CollectionDto>,
16 AppResponseData<crate::services::collections::CollectionPage>,
17 export::rest::NoData,
18 space::services::spaces::SpaceNamePath,
19 crate::services::collections::CollectionDto,
20 crate::services::collections::CollectionPage,
21 crate::services::collections::CreateCollectionCmd,
22 crate::services::collections::UpdateCollectionCmd,
23 crate::services::collections::ListCollectionQuery,
24 crate::services::collections::CollectionPath,
25 models::sagent::agent::AgentState,
26 models::sagent::agent::AgentPriority,
27 )),
828 tags(
929 (name = "sagent", description = "Sandbox agent API")
1030 )
1131)]
12pub struct SagentsApiDoc;
No newline at end of file
32pub struct SagentsApiDoc;
Modified lib/sagent/src/routes.rs View lib/sagent/src/routes.rs
@@ -1,11 +1,28 @@
11use axum::Router;
2use axum::routing::{get, post};
23
4use crate::handlers::collections::create_collection::create_collection;
5use crate::handlers::collections::delete_collection::delete_collection;
6use crate::handlers::collections::get_collection::get_collection;
7use crate::handlers::collections::list_collections::list_collections;
8use crate::handlers::collections::update_collection::update_collection;
39use crate::services::service::SagentService;
410use crate::share::guard::sagent_guard;
511
612pub fn router(service: SagentService) -> Router<SagentService> {
7 Router::new().route_layer(axum::middleware::from_fn_with_state(
8 service.clone(),
9 sagent_guard,
10 ))
11}
No newline at end of file
13 Router::new()
14 .route(
15 "/agents/{space_name}/collections",
16 post(create_collection).get(list_collections),
17 )
18 .route(
19 "/agents/{space_name}/collections/{collection_id}",
20 get(get_collection)
21 .patch(update_collection)
22 .delete(delete_collection),
23 )
24 .route_layer(axum::middleware::from_fn_with_state(
25 service.clone(),
26 sagent_guard,
27 ))
28}