chore(sagent): remove the agent SDK, models and web console
Drops the unused `AdminClient`, the `sagent` agent entity, the agent message catalogs and the task collaboration panels with them.
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified app/apihost/src/main.rs app/apihost/src/main.rs +0 −7 View app/apihost/src/main.rs
@@ -36,13 +36,6 @@
3636 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
3737 tokio::spawn(auth::worker::run_deletion_executor(state.clone()));
3838
39 if cfg.sagent.is_on() {
40 tokio::spawn(
41 sagent::services::service::SagentService::new(state.clone()).run_reconcile_loop(),
42 );
43 info!("sagent reconcile loop started");
44 }
45
4639 // Web Push 消费者:与其它后台任务一起挂在 apihost 上,不为它单开进程。
4740 // 配置非法(密钥缺失或公私钥不成对)时直接报错退出——静默不投递比启动
4841 // 失败更难被发现。
Modified app/apihost/Cargo.toml app/apihost/Cargo.toml +0 −1 View app/apihost/Cargo.toml
@@ -9,7 +9,6 @@
99auth = { workspace = true }
1010chat = { workspace = true }
1111push = { workspace = true }
12sagent = { workspace = true }
1312export = { workspace = true }
1413session = { workspace = true }
1514config = { workspace = true }
Modified chart/templates/_helpers.tpl chart/templates/_helpers.tpl +0 −6 View chart/templates/_helpers.tpl
@@ -124,12 +124,6 @@
124124- name: APP_AIHOST__ENABLED
125125 value: "false"
126126{{- end }}
127{{- if .Values.sagent.enabled }}
128- name: APP_SAGENT__ENABLED
129 value: "true"
130- name: APP_SAGENT__BASE_URL
131 value: {{ .Values.sagent.baseUrl | quote }}
132{{- end }}
133127{{- if .Values.githost.ssh.enabled }}
134128- name: APP_GIT__SSH__PORT
135129 value: {{ .Values.githost.ssh.containerPort | quote }}
Modified chart/values.yaml chart/values.yaml +0 −8 View chart/values.yaml
@@ -240,14 +240,6 @@
240240 botDisplayName: "AI Reviewer"
241241 outputLanguage: "zh-CN"
242242
243# ---------------------------------------------------------------------------
244# 沙箱智能体(agent gateway):任务在远端沙箱里跑,本仓只做建模、审计与落库。
245# 密钥走 secret.env(APP_SAGENT__ADMIN_KEY)。
246# ---------------------------------------------------------------------------
247sagent:
248 enabled: false
249 baseUrl: ""
250
251243aihost:
252244 enabled: true
253245 replicaCount: 1
Modified config/default.toml config/default.toml +1 −31 View config/default.toml
@@ -2,7 +2,6 @@
22bucket = ""
33region = "us-east-1"
44endpoint = ""
5# Presigned URL 使用的对外端点(浏览器/客户端必须可达)。留空则沿用 endpoint。
65public_endpoint = ""
76access_key_id = ""
87secret_access_key = ""
@@ -387,26 +386,16 @@
387386keep_workspace = false
388387once = false
389388
390# ---------------------------------------------------------------------------
391# Web Push(RFC8030/RFC8292):聊天与站内通知的操作系统级推送。
392# 默认关闭;启用前必须用 P-256 密钥对填好 VAPID 公钥/私钥:
393# openssl ecparam -genkey -name prime256v1 -out vapid.pem
394# openssl ec -in vapid.pem -outform DER | tail -c 32 | base64 | tr '+/' '-_' | tr -d '=' # 私钥
395# openssl ec -in vapid.pem -pubout -outform DER | tail -c 65 | base64 | tr '+/' '-_' | tr -d '=' # 公钥
396# 密钥材料均为 base64url(无填充):私钥 = 32 字节标量,公钥 = 65 字节非压缩点。
397# ---------------------------------------------------------------------------
389
398390[push]
399391enabled = false
400392vapid_public_key = ""
401393vapid_private_key = ""
402394vapid_subject = "mailto:ops@example.com"
403
404# apihost 内的消费者数量;推送是"尽力而为"通知,不做多副本协同。
405395workers = 2
406396max_attempts = 3
407397retry_backoff_base_secs = 30
408398request_timeout_secs = 10
409# 推送服务保留期(HTTP TTL 头);设备离线超过该时长则丢弃。
410399ttl_secs = 86400
411400
412401[livekit]
@@ -423,22 +412,3 @@
423412hand_ttl_secs = 3600
424413recording_enabled = false
425414
426[sagent]
427# 沙箱智能体(lib/sagent)。admin_key 只走 env:APP_SAGENT__ADMIN_KEY;
428# 会话令牌由 agent gateway 用 admin key 签发,本仓不持有签名密钥。
429enabled = false
430base_url = "https://agent.gitdata.ai"
431admin_key = ""
432session_ttl_secs = 600
433tenant_id = "3f9a2c1e-77d4-4b18-9c0e-64b2f5a1d001"
434max_pagination = 100
435default_pagination = 20
436max_collection_name_len = 100
437max_label_name_len = 50
438max_title_len = 255
439max_body_len = 65535
440max_message_len = 65535
441max_mentions_per_message = 20
442request_timeout_secs = 60
443subscribe_reconnect_max = 5
444reconcile_interval_secs = 60
Modified deploy/values/prod.yaml deploy/values/prod.yaml +0 −16 View deploy/values/prod.yaml
@@ -1,10 +1,3 @@
1# GitData 生产 values(非敏感部分,入库)
2# 域名策略: 主站/业务服务 *.gitdata.ai;内部服务 *.gitdata.me
3#
4# 敏感值(secret.env)不在此文件:由 CI 从 Gitea Secret `PROD_SECRET_VALUES`
5# 拼接注入,见 .gitea/workflows/deploy.yaml 与 deploy/README.md。
6# image.tag 由 CI 按本次提交的 sha 覆盖。
7
81image:
92 repository: harbor.gitdata.me/app/gitdata
103 tag: ""
@@ -15,7 +8,6 @@
158
169config:
1710 env:
18
1911 APP_REDIS__MODE: "cluster"
2012 APP_REDIS__POOL_SIZE: "8"
2113 APP_CACHE__REDIS__MODE: "standalone"
@@ -46,7 +38,6 @@
4638 APP_EMAIL__FROM_ADDRESS: "bot@mail.gitdata.ai"
4739 APP_EMAIL__FROM_NAME: "GitData"
4840 APP_EMAIL_QUEUE__ENABLED: "true"
49
5041 RUST_LOG: "info,axum_session::session_store=error"
5142 OTEL_TRACES_SAMPLER: "parentbased_traceidratio"
5243 OTEL_TRACES_SAMPLER_ARG: "0.2"
@@ -159,13 +150,6 @@
159150 botDisplayName: "AI Reviewer"
160151 outputLanguage: "zh-CN"
161152
162sagent:
163 # 暂时关闭:sagent 的 admin_key 是必填项(config 校验会拒绝空值),
164 # 而 agent.gitdata.ai 是集群外的服务,密钥只能由对方平台提供。
165 # 拿到 key 后:写进 PROD_SECRET_VALUES 的 APP_SAGENT__ADMIN_KEY
166 # (secret.env 走 envFrom,不需要改 chart),再把这里改回 true。
167 enabled: false
168 baseUrl: "https://agent.gitdata.ai"
169153
170154aihost:
171155 enabled: true
Deleted deploy/README.md deploy/README.md +0 −96
@@ -1,96 +0,0 @@
1# 部署
2
3生产部署由 `.gitea/workflows/deploy.yaml` 驱动,推到 `main`/`master` 即自动发布。
4
5## 前提
6
7- **Gitea Runner 已配置 KUBECONFIG**(指向 `gitdata` 集群),workflow 不再注入集群凭据。
8- Runner 能访问 `harbor.gitdata.me`(拉取镜像 tag 列表 + 后续 `helm upgrade` 触发的镜像拉取)。
9- 仓库已配置以下 **Repository Secrets**(Settings → Actions → Secrets):
10
11| Secret | 用途 |
12| --- | --- |
13| `PROD_SECRET_VALUES` | 生产 values 的 `secret:` 块(见下) |
14| `DOCKER_USERNAME` / `DOCKER_PASSWORD` | Harbor 登录,用于校验镜像已推送 |
15| `DOCKER_REGISTER` | 可选,默认 `harbor.gitdata.me` |
16
17## 配置拆分:为什么有两个文件
18
19Helm values 分成两层,避免把凭据写进 git:
20
21| 文件 | 内容 | 入库 |
22| --- | --- | --- |
23| `deploy/values/prod.yaml` | 域名、副本数、资源配置、AI 模型等**非敏感**项 | ✅ |
24| Gitea Secret `PROD_SECRET_VALUES` | 数据库 URL、SMTP 口令、各签名/加密密钥、Redis 口令、AI API key | ❌ |
25
26`PROD_SECRET_VALUES` 的完整内容(**20 项**,直接复制粘贴到 Gitea 即可):
27
28```yaml
29secret:
30 create: true
31 env:
32 APP_DATABASE__URL: "..."
33 APP_REDIS__URL: "..."
34 APP_CACHE__REDIS__URL: "..."
35 APP_S3__ACCESS_KEY_ID: "..."
36 APP_S3__SECRET_ACCESS_KEY: "..."
37 APP_SEARCH__API_KEY: "..."
38 APP_LIVEKIT__API_KEY: "..."
39 APP_LIVEKIT__API_SECRET: "..."
40 APP_SESSION__KEY: "..."
41 APP_SESSION__DATABASE_KEY: "..."
42 APP_POW__MAC_KEY: "..."
43 APP_AUTH__TOTP_ENCRYPTION_KEY: "..."
44 APP_AUTH__CHAT_JWT_SEC: "..."
45 APP_CI__SECRET_ENCRYPTION_KEY: "..."
46 APP_IMPORT__TOKEN_SECRET: "..."
47 APP_SAGENT__ADMIN_KEY: "..."
48 APP_AI__API_KEY: "..."
49 APP_EMAIL__SMTP_PASSWORD: "..."
50 APP_ADMIN__SEED_USERNAME: "..."
51 APP_ADMIN__SEED_PASSWORD: "..."
52```
53
54实际值在集群运维手上(本机备份:`C:\Users\ZhenYi\k8s-backups\<date>\gitea-secret-values.yaml`)。
55**轮换任何密钥时,只需改 Gitea Secret,不用改仓库。**
56
57## Workflow 做什么
58
591. **Resolve image tag** — 默认取本次提交的 `sha-<short>`,可手动指定
602. **Verify image exists** — 轮询 Harbor tags API(最长 ~10min)。
61 `docker-build-push` 与本 workflow 并行触发,不加这步会 `ImagePullBackOff`
623. **Assemble values** — `prod.yaml` + `PROD_SECRET_VALUES` 拼成一份,日志里脱敏
634. **Render** — `helm template` 先渲染,语法/模板错误在这里就暴露
645. **Diff** — 与线上 manifest 逐行对比,评审者可在日志里看到本次真实改动
656. **Deploy** — `helm upgrade --install --atomic --wait --timeout 15m`
66 `--atomic` 保证失败自动回滚到上一版本
677. **Verify rollout** — pod 状态、实际镜像、逐 deployment rollout、Warning 事件
68
69## 手动操作
70
71```bash
72# 仅校验不部署
73gitea: workflow_dispatch -> dry_run=true
74
75# 指定镜像 tag 重发(例如回滚到某个已构建的镜像)
76gitea: workflow_dispatch -> image_tag=sha-xxxxxxx
77
78# 本地复现 CI 的渲染步骤
79cat deploy/values/prod.yaml > /tmp/values.yaml
80printf '\n' >> /tmp/values.yaml
81cat /path/to/gitea-secret-values.yaml >> /tmp/values.yaml
82helm template gitdata ./chart --namespace gitdata --set image.tag=sha-xxxxxxx -f /tmp/values.yaml
83
84# 查看当前 release
85helm history gitdata -n gitdata
86```
87
88## 注意
89
90- **部署串行**:`concurrency.group = deploy-gitdata` 且 `cancel-in-progress: false`,
91 避免两次 upgrade 互相打断。
92- **迁移 Job**:`migrate.enabled=true` 时作为 pre-upgrade hook 运行,失败会阻断发布。
93- **PVC 不在 chart 管理范围**:`gitdata-repos`(2Ti)由 kubectl 独立管理,
94 `helm uninstall` 不会回收数据。
95- **回滚**:`helm rollback gitdata <revision> -n gitdata`,
96 或用 `image_tag` 重发上一个镜像。
Deleted lib/agent-sdk/examples/prod_probe.rs lib/agent-sdk/examples/prod_probe.rs +0 −64
@@ -1,64 +0,0 @@
1//! 生产连通性探测(Rust SDK 全路径):
2//! AdminClient 建会话 → 签发会话令牌 → Client 读会话/消息 → 订阅会话 SSE → 发消息触发 Run → 取消。
3//!
4//! 用法:cargo run -p agent-sdk --example prod_probe -- <base_url> <admin_key> <tenant_id>
5
6use std::time::Duration;
7
8use agent_sdk::{AdminClient, Client};
9use agent_types::ids::TenantId;
10
11#[tokio::main]
12async fn main() -> Result<(), Box<dyn std::error::Error>> {
13 let mut args = std::env::args().skip(1);
14 let usage = "用法: prod_probe <base_url> <admin_key> <tenant_id>";
15 let base = args.next().ok_or(usage)?;
16 let admin_key = args.next().ok_or(usage)?;
17 let tenant: TenantId = args.next().ok_or(usage)?.parse()?;
18
19 let admin = AdminClient::new(&base, admin_key)?;
20
21 println!("[1] admin 建会话 POST /api/v1/admin/sessions");
22 let session = admin.create_session(None, Some("sdk 连通性探测"), None).await?;
23 println!(" OK session_id={}", session.session_id);
24
25 println!("[2] admin 签发会话令牌 POST /api/v1/admin/sessions/{{id}}/tokens");
26 let issued =
27 admin.issue_session_token(session.session_id, tenant, "sdk-probe", Some(600)).await?;
28 println!(
29 " OK token_len={} expires_in={}s grant={}",
30 issued.token.len(),
31 issued.expires_in_secs,
32 issued.grant_id
33 );
34
35 let client = Client::new(&base, issued.token.to_string(), session.session_id)?;
36
37 println!("[3] 业务面 GET /api/v1/sessions/{{id}}");
38 let got = client.get_session().await?;
39 println!(" OK title={:?} created_at={}", got.title, got.created_at_millis);
40
41 println!("[4] 业务面 GET /api/v1/sessions/{{id}}/messages");
42 let page = client.list_messages(Some(10), None).await?;
43 println!(" OK messages={} has_more={}", page.items.len(), page.next_cursor.is_some());
44
45 println!("[5] 订阅 SSE GET /api/v1/sessions/{{id}}/events(等首帧,最长 8s)");
46 let mut stream = client.stream_events(None).await?;
47 match tokio::time::timeout(Duration::from_secs(8), stream.next()).await {
48 Ok(Some(Ok(ev))) => println!(" OK 首帧 #1: {ev:?}"),
49 Ok(Some(Err(e))) => return Err(format!("SSE 错误: {e}").into()),
50 Ok(None) => println!(" 流结束(无帧)"),
51 Err(_) => println!(" 8s 内无帧(会话空闲,订阅通道已建立即视为连通)"),
52 }
53
54 println!("[6] 业务面 POST /api/v1/sessions/{{id}}/messages(触发 Run)");
55 let accepted = client.send_message("连通性探测:请只回复 OK 两个字。", None).await?;
56 println!(" OK run_id={} state={:?}", accepted.run_id, accepted.state);
57
58 println!("[7] 业务面 POST /api/v1/runs/{{id}}/cancel");
59 let cancelled = client.cancel_run(accepted.run_id).await?;
60 println!(" OK state={:?}", cancelled.state);
61
62 println!("\n结果: Rust SDK 生产连通性 7/7 通过 ✅");
63 Ok(())
64}
Deleted lib/agent-sdk/src/admin.rs lib/agent-sdk/src/admin.rs +0 −192
@@ -1,192 +0,0 @@
1use agent_types::api::{Page, RunDto};
2use agent_types::ids::{SessionId, TenantId};
3use agent_types::session::{
4 CreateSessionRequest, CreateSessionTokenRequest, MessageDto, SessionDto, SessionTokenDto,
5};
6use reqwest::Method;
7use smol_str::SmolStr;
8
9use crate::error::SdkError;
10use crate::normalize_base;
11
12#[derive(Clone)]
13pub struct AdminClient {
14 http: reqwest::Client,
15 base: String,
16 key: String,
17}
18
19impl std::fmt::Debug for AdminClient {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 f.debug_struct("AdminClient").field("base", &self.base).finish_non_exhaustive()
22 }
23}
24
25impl AdminClient {
26 pub fn new(base_url: &str, admin_key: impl Into<String>) -> Result<Self, SdkError> {
27 let base = normalize_base(base_url);
28 if base.is_empty() {
29 return Err(SdkError::Invalid("base_url 不能为空".into()));
30 }
31 let key = admin_key.into();
32 if key.trim().is_empty() {
33 return Err(SdkError::Invalid("admin_key 不能为空".into()));
34 }
35 let http =
36 reqwest::Client::builder().build().map_err(|e| SdkError::Transport(e.to_string()))?;
37 Ok(Self { http, base, key })
38 }
39
40 fn url(&self, path: &str) -> String {
41 format!("{}{path}", self.base)
42 }
43
44 async fn send_json<T: serde::de::DeserializeOwned>(
45 &self,
46 method: Method,
47 path: &str,
48 body: Option<serde_json::Value>,
49 ) -> Result<T, SdkError> {
50 let mut req = self
51 .http
52 .request(method, self.url(path))
53 .header("authorization", format!("Bearer {}", self.key));
54 if let Some(body) = body {
55 req = req.json(&body);
56 }
57 let res = req.send().await.map_err(|e| SdkError::Transport(e.to_string()))?;
58 let status = res.status().as_u16();
59 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
60 if !(200..300).contains(&status) {
61 return Err(SdkError::from_response(status, &text));
62 }
63 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
64 }
65
66 async fn send_empty(&self, method: Method, path: &str) -> Result<(), SdkError> {
67 let res = self
68 .http
69 .request(method, self.url(path))
70 .header("authorization", format!("Bearer {}", self.key))
71 .send()
72 .await
73 .map_err(|e| SdkError::Transport(e.to_string()))?;
74 let status = res.status().as_u16();
75 if (200..300).contains(&status) {
76 return Ok(());
77 }
78 let text = res.text().await.unwrap_or_default();
79 Err(SdkError::from_response(status, &text))
80 }
81
82 pub async fn create_session(
83 &self,
84 session_id: Option<SessionId>,
85 title: Option<&str>,
86 metadata: Option<serde_json::Value>,
87 ) -> Result<SessionDto, SdkError> {
88 let body = serde_json::to_value(CreateSessionRequest {
89 session_id,
90 title: title.map(SmolStr::new),
91 metadata,
92 })
93 .map_err(|e| SdkError::Decode(e.to_string()))?;
94 self.send_json(Method::POST, "/api/v1/admin/sessions", Some(body)).await
95 }
96
97 pub async fn list_sessions(
98 &self,
99 limit: Option<u32>,
100 cursor: Option<SessionId>,
101 include_deleted: bool,
102 ) -> Result<Page<SessionDto>, SdkError> {
103 let mut path = format!("/api/v1/admin/sessions?include_deleted={include_deleted}",);
104 if let Some(limit) = limit {
105 path.push_str(&format!("&limit={limit}"));
106 }
107 if let Some(cursor) = cursor {
108 path.push_str(&format!("&cursor={cursor}"));
109 }
110 self.send_json(Method::GET, &path, None).await
111 }
112
113 pub async fn get_session(&self, session_id: SessionId) -> Result<SessionDto, SdkError> {
114 self.send_json(Method::GET, &format!("/api/v1/admin/sessions/{session_id}"), None).await
115 }
116
117 pub async fn delete_session(&self, session_id: SessionId) -> Result<(), SdkError> {
118 self.send_empty(Method::DELETE, &format!("/api/v1/admin/sessions/{session_id}")).await
119 }
120
121 pub async fn list_messages(
122 &self,
123 session_id: SessionId,
124 limit: Option<u32>,
125 cursor: Option<u64>,
126 ) -> Result<Page<MessageDto>, SdkError> {
127 let mut path = format!("/api/v1/admin/sessions/{session_id}/messages?");
128 if let Some(limit) = limit {
129 path.push_str(&format!("limit={limit}&"));
130 }
131 if let Some(cursor) = cursor {
132 path.push_str(&format!("cursor={cursor}&"));
133 }
134 self.send_json(Method::GET, path.trim_end_matches(['?', '&']), None).await
135 }
136
137 pub async fn issue_session_token(
138 &self,
139 session_id: SessionId,
140 tenant: TenantId,
141 subject: &str,
142 ttl_secs: Option<u64>,
143 ) -> Result<SessionTokenDto, SdkError> {
144 self.issue_session_token_with_scopes(session_id, tenant, subject, ttl_secs, None).await
145 }
146
147 /// 显式收窄权限:`scopes` 是权限字面量子集(如 `runs:read`、`sessions:*`),
148 /// 必须落在 invoke 用途上限之内。权限的权威在服务端登记行,因此
149 /// 后续收窄对已签发但未过期的令牌同样生效。
150 pub async fn issue_session_token_with_scopes(
151 &self,
152 session_id: SessionId,
153 tenant: TenantId,
154 subject: &str,
155 ttl_secs: Option<u64>,
156 scopes: Option<Vec<&str>>,
157 ) -> Result<SessionTokenDto, SdkError> {
158 let body = serde_json::to_value(CreateSessionTokenRequest {
159 tenant,
160 subject: SmolStr::new(subject),
161 subject_kind: None,
162 ttl_secs,
163 scopes: scopes.map(|list| list.into_iter().map(SmolStr::new).collect()),
164 })
165 .map_err(|e| SdkError::Decode(e.to_string()))?;
166 self.send_json(
167 Method::POST,
168 &format!("/api/v1/admin/sessions/{session_id}/tokens"),
169 Some(body),
170 )
171 .await
172 }
173
174 pub async fn list_runs(
175 &self,
176 tenant: Option<TenantId>,
177 session_id: Option<SessionId>,
178 limit: Option<u32>,
179 ) -> Result<Page<RunDto>, SdkError> {
180 let mut path = String::from("/api/v1/admin/runs?");
181 if let Some(tenant) = tenant {
182 path.push_str(&format!("tenant={tenant}&"));
183 }
184 if let Some(session_id) = session_id {
185 path.push_str(&format!("session_id={session_id}&"));
186 }
187 if let Some(limit) = limit {
188 path.push_str(&format!("limit={limit}&"));
189 }
190 self.send_json(Method::GET, path.trim_end_matches(['?', '&']), None).await
191 }
192}
Deleted lib/agent-sdk/src/client.rs lib/agent-sdk/src/client.rs +0 −214
@@ -1,214 +0,0 @@
1use agent_types::api::{Page, RunDto};
2use agent_types::ids::{RunId, SessionId};
3use agent_types::session::{MessageAccepted, MessageDto, SendMessageRequest, SessionDto};
4use futures::StreamExt as _;
5use smol_str::SmolStr;
6
7use crate::error::SdkError;
8use crate::normalize_base;
9use crate::sse::{SseEvent, SseParser};
10
11#[derive(Clone)]
12pub struct Client {
13 http: reqwest::Client,
14 base: String,
15 token: String,
16 session_id: SessionId,
17}
18
19impl std::fmt::Debug for Client {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 f.debug_struct("Client")
22 .field("base", &self.base)
23 .field("session_id", &self.session_id)
24 .finish_non_exhaustive()
25 }
26}
27
28impl Client {
29 pub fn new(
30 base_url: &str,
31 token: impl Into<String>,
32 session_id: SessionId,
33 ) -> Result<Self, SdkError> {
34 let base = normalize_base(base_url);
35 if base.is_empty() {
36 return Err(SdkError::Invalid("base_url 不能为空".into()));
37 }
38 let token = token.into();
39 if token.trim().is_empty() {
40 return Err(SdkError::Invalid("token 不能为空".into()));
41 }
42 let http =
43 reqwest::Client::builder().build().map_err(|e| SdkError::Transport(e.to_string()))?;
44 Ok(Self { http, base, token, session_id })
45 }
46
47 #[must_use]
48 pub const fn session_id(&self) -> SessionId {
49 self.session_id
50 }
51
52 fn url(&self, path: &str) -> String {
53 format!("{}{path}", self.base)
54 }
55
56 async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, SdkError> {
57 let res = self
58 .http
59 .get(self.url(path))
60 .header("authorization", format!("Bearer {}", self.token))
61 .send()
62 .await
63 .map_err(|e| SdkError::Transport(e.to_string()))?;
64 let status = res.status().as_u16();
65 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
66 if !(200..300).contains(&status) {
67 return Err(SdkError::from_response(status, &text));
68 }
69 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
70 }
71
72 pub async fn get_session(&self) -> Result<SessionDto, SdkError> {
73 self.get_json(&format!("/api/v1/sessions/{}", self.session_id)).await
74 }
75
76 pub async fn list_messages(
77 &self,
78 limit: Option<u32>,
79 cursor: Option<u64>,
80 ) -> Result<Page<MessageDto>, SdkError> {
81 let mut path = format!("/api/v1/sessions/{}/messages?", self.session_id);
82 if let Some(limit) = limit {
83 path.push_str(&format!("limit={limit}&"));
84 }
85 if let Some(cursor) = cursor {
86 path.push_str(&format!("cursor={cursor}&"));
87 }
88 self.get_json(path.trim_end_matches(['?', '&'])).await
89 }
90
91 pub async fn send_message(
92 &self,
93 text: impl Into<SmolStr>,
94 idempotency_key: Option<SmolStr>,
95 ) -> Result<MessageAccepted, SdkError> {
96 let body = serde_json::to_value(SendMessageRequest {
97 text: text.into(),
98 idempotency_key,
99 limits: None,
100 })
101 .map_err(|e| SdkError::Decode(e.to_string()))?;
102 let res = self
103 .http
104 .post(self.url(&format!("/api/v1/sessions/{}/messages", self.session_id)))
105 .header("authorization", format!("Bearer {}", self.token))
106 .json(&body)
107 .send()
108 .await
109 .map_err(|e| SdkError::Transport(e.to_string()))?;
110 let status = res.status().as_u16();
111 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
112 if !(200..300).contains(&status) {
113 return Err(SdkError::from_response(status, &text));
114 }
115 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
116 }
117
118 pub async fn get_run(&self, run_id: RunId) -> Result<RunDto, SdkError> {
119 self.get_json(&format!("/api/v1/runs/{run_id}")).await
120 }
121
122 pub async fn list_runs(&self, limit: Option<u32>) -> Result<Page<RunDto>, SdkError> {
123 let path = match limit {
124 Some(limit) => {
125 format!("/api/v1/runs?session_id={}&limit={limit}", self.session_id)
126 }
127 None => format!("/api/v1/runs?session_id={}", self.session_id),
128 };
129 self.get_json(&path).await
130 }
131
132 pub async fn cancel_run(&self, run_id: RunId) -> Result<RunDto, SdkError> {
133 let res = self
134 .http
135 .post(self.url(&format!("/api/v1/runs/{run_id}/cancel")))
136 .header("authorization", format!("Bearer {}", self.token))
137 .json(&serde_json::json!({}))
138 .send()
139 .await
140 .map_err(|e| SdkError::Transport(e.to_string()))?;
141 let status = res.status().as_u16();
142 let text = res.text().await.map_err(|e| SdkError::Transport(e.to_string()))?;
143 if !(200..300).contains(&status) {
144 return Err(SdkError::from_response(status, &text));
145 }
146 serde_json::from_str(&text).map_err(|e| SdkError::Decode(e.to_string()))
147 }
148
149 pub async fn stream_events(&self, last_event_id: Option<u64>) -> Result<EventStream, SdkError> {
150 let mut req = self
151 .http
152 .get(self.url(&format!("/api/v1/sessions/{}/events", self.session_id)))
153 .header("authorization", format!("Bearer {}", self.token))
154 .header("accept", "text/event-stream");
155 if let Some(seq) = last_event_id {
156 req = req.header("last-event-id", format!("m{seq}"));
157 }
158 let res = req.send().await.map_err(|e| SdkError::Transport(e.to_string()))?;
159 let status = res.status().as_u16();
160 if !(200..300).contains(&status) {
161 let text = res.text().await.unwrap_or_default();
162 return Err(SdkError::from_response(status, &text));
163 }
164 Ok(EventStream {
165 inner: Box::pin(res.bytes_stream()),
166 parser: SseParser::new(),
167 watermark: last_event_id.unwrap_or(0),
168 })
169 }
170}
171
172pub struct EventStream {
173 inner:
174 std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Send>>,
175 parser: SseParser,
176 watermark: u64,
177}
178
179impl EventStream {
180 pub async fn next(&mut self) -> Option<Result<SseEvent, SdkError>> {
181 loop {
182 if let Some(frame) = self.parser.pop_frame() {
183 let event = SseEvent::parse(&frame);
184 if let Some(watermark) = event.as_ref().and_then(SseEvent::watermark) {
185 self.watermark = watermark;
186 }
187 return Some(
188 event.ok_or_else(|| {
189 SdkError::Decode(format!("无法解析 SSE 帧: {}", frame.event))
190 }),
191 );
192 }
193 match self.inner.next().await {
194 Some(Ok(chunk)) => self.parser.push(&chunk),
195 Some(Err(e)) => return Some(Err(SdkError::Transport(e.to_string()))),
196 None => {
197 let frame = self.parser.flush()?;
198 let event = SseEvent::parse(&frame);
199 if let Some(watermark) = event.as_ref().and_then(SseEvent::watermark) {
200 self.watermark = watermark;
201 }
202 return Some(event.ok_or_else(|| {
203 SdkError::Decode(format!("无法解析 SSE 帧: {}", frame.event))
204 }));
205 }
206 }
207 }
208 }
209
210 #[must_use]
211 pub const fn watermark(&self) -> u64 {
212 self.watermark
213 }
214}
Deleted lib/agent-sdk/src/error.rs lib/agent-sdk/src/error.rs +0 −99
@@ -1,99 +0,0 @@
1use agent_types::api::ApiErrorCode;
2
3#[derive(Debug, thiserror::Error)]
4pub enum SdkError {
5 #[error("transport: {0}")]
6 Transport(String),
7 #[error("unauthorized: {0}")]
8 Unauthorized(String),
9 #[error("forbidden: {0}")]
10 Forbidden(String),
11 #[error("not found: {0}")]
12 NotFound(String),
13 #[error("conflict: {0}")]
14 Conflict(String),
15 #[error("gone: {0}")]
16 Gone(String),
17 #[error("rate limited: {0}")]
18 RateLimited(String),
19 #[error("bad request: {0}")]
20 BadRequest(String),
21 #[error("server error ({status}): {message}")]
22 Server { status: u16, message: String },
23 #[error("decode: {0}")]
24 Decode(String),
25 #[error("invalid argument: {0}")]
26 Invalid(String),
27}
28
29impl SdkError {
30 #[must_use]
31 pub const fn is_retryable(&self) -> bool {
32 match self {
33 Self::Transport(_) | Self::RateLimited(_) => true,
34 Self::Server { status, .. } => *status >= 500,
35 _ => false,
36 }
37 }
38
39 pub(crate) fn from_response(status: u16, body: &str) -> Self {
40 let parsed: Option<agent_types::api::ApiError> = serde_json::from_str(body).ok();
41 let message = parsed
42 .as_ref()
43 .map(|e| e.message.to_string())
44 .unwrap_or_else(|| body.trim().to_string());
45 match crate::error::code_from_status(status, parsed.as_ref().map(|e| e.code)) {
46 ApiErrorCode::Unauthenticated => Self::Unauthorized(message),
47 ApiErrorCode::Forbidden => Self::Forbidden(message),
48 ApiErrorCode::NotFound => Self::NotFound(message),
49 ApiErrorCode::Conflict => Self::Conflict(message),
50 ApiErrorCode::Gone => Self::Gone(message),
51 ApiErrorCode::TooManyRequests => Self::RateLimited(message),
52 ApiErrorCode::BadRequest => Self::BadRequest(message),
53 _ => Self::Server { status, message },
54 }
55 }
56}
57
58pub(crate) fn code_from_status(status: u16, code: Option<ApiErrorCode>) -> ApiErrorCode {
59 if let Some(code) = code {
60 return code;
61 }
62 match status {
63 400 => ApiErrorCode::BadRequest,
64 401 => ApiErrorCode::Unauthenticated,
65 403 => ApiErrorCode::Forbidden,
66 404 => ApiErrorCode::NotFound,
67 409 => ApiErrorCode::Conflict,
68 410 => ApiErrorCode::Gone,
69 429 => ApiErrorCode::TooManyRequests,
70 _ => ApiErrorCode::Internal,
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn error_body_drives_the_variant() {
80 let body = r#"{"code":"forbidden","message":"跨会话访问被拒绝"}"#;
81 let err = SdkError::from_response(403, body);
82 assert!(matches!(err, SdkError::Forbidden(ref m) if m == "跨会话访问被拒绝"));
83 }
84
85 #[test]
86 fn non_json_body_falls_back_to_status() {
87 let err = SdkError::from_response(404, "not found");
88 assert!(matches!(err, SdkError::NotFound(ref m) if m == "not found"));
89 }
90
91 #[test]
92 fn retryability_matches_transport_and_5xx_only() {
93 assert!(SdkError::Server { status: 503, message: "down".into() }.is_retryable());
94 assert!(SdkError::Transport("timeout".into()).is_retryable());
95 assert!(!SdkError::BadRequest("bad".into()).is_retryable());
96 assert!(!SdkError::NotFound("x".into()).is_retryable());
97 assert!(!SdkError::Forbidden("x".into()).is_retryable());
98 }
99}
Deleted lib/agent-sdk/src/lib.rs lib/agent-sdk/src/lib.rs +0 −16
@@ -1,16 +0,0 @@
1#![warn(clippy::unwrap_used, clippy::expect_used)]
2
3mod admin;
4mod client;
5mod error;
6mod sse;
7
8pub use admin::AdminClient;
9pub use client::{Client, EventStream};
10pub use error::SdkError;
11pub use sse::{SseEvent, SseFrame};
12
13#[must_use]
14pub(crate) fn normalize_base(base: &str) -> String {
15 base.trim().trim_end_matches('/').to_string()
16}
Deleted lib/agent-sdk/src/sse.rs lib/agent-sdk/src/sse.rs +0 −156
@@ -1,156 +0,0 @@
1use agent_types::ids::{RunId, SessionId};
2use agent_types::session::MessageDto;
3
4#[derive(Debug, Clone, PartialEq)]
5pub struct SseFrame {
6 pub event: String,
7 pub id: Option<String>,
8 pub data: String,
9}
10
11#[derive(Debug, Clone, PartialEq)]
12pub enum SseEvent {
13 MessageAdded { session_id: SessionId, message: MessageDto },
14 RunEvent { run_id: RunId, seq: u64, event: agent_types::event::AgentEvent },
15 RunState { run_id: RunId, state: agent_types::state::RunState },
16 Other { event: String, data: String },
17}
18
19impl SseEvent {
20 #[must_use]
21 pub fn parse(frame: &SseFrame) -> Option<Self> {
22 match frame.event.as_str() {
23 "message_added" => {
24 let message: MessageDto = serde_json::from_str(&frame.data).ok()?;
25 Some(Self::MessageAdded { session_id: message.session_id, message })
26 }
27 "run_event" => {
28 #[derive(serde::Deserialize)]
29 struct Payload {
30 run_id: RunId,
31 seq: u64,
32 event: agent_types::event::AgentEvent,
33 }
34 let p: Payload = serde_json::from_str(&frame.data).ok()?;
35 Some(Self::RunEvent { run_id: p.run_id, seq: p.seq, event: p.event })
36 }
37 "run_state" => {
38 #[derive(serde::Deserialize)]
39 struct Payload {
40 run_id: RunId,
41 state: agent_types::state::RunState,
42 }
43 let p: Payload = serde_json::from_str(&frame.data).ok()?;
44 Some(Self::RunState { run_id: p.run_id, state: p.state })
45 }
46 other => Some(Self::Other { event: other.to_string(), data: frame.data.clone() }),
47 }
48 }
49
50 #[must_use]
51 pub fn watermark(&self) -> Option<u64> {
52 match self {
53 Self::MessageAdded { message, .. } => Some(message.seq),
54 _ => None,
55 }
56 }
57}
58#[derive(Debug, Default)]
59pub struct SseParser {
60 buffer: Vec<u8>,
61}
62
63impl SseParser {
64 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn push(&mut self, chunk: &[u8]) {
70 self.buffer.extend_from_slice(chunk);
71 }
72
73 pub fn pop_frame(&mut self) -> Option<SseFrame> {
74 let sep = self.buffer.windows(2).position(|w| w == b"\n\n")?;
75 let head = self.buffer[..sep].to_vec();
76 self.buffer.drain(..sep + 2);
77 parse_block(&head)
78 }
79
80 pub fn flush(&mut self) -> Option<SseFrame> {
81 if self.buffer.iter().all(u8::is_ascii_whitespace) {
82 self.buffer.clear();
83 return None;
84 }
85 let block = std::mem::take(&mut self.buffer);
86 parse_block(&block)
87 }
88}
89
90fn parse_block(raw: &[u8]) -> Option<SseFrame> {
91 let block = std::str::from_utf8(raw).ok()?;
92 let mut event = None;
93 let mut id = None;
94 let mut data = String::new();
95 for line in block.lines() {
96 if let Some(value) = line.strip_prefix("event:") {
97 event = Some(value.trim().to_string());
98 } else if let Some(value) = line.strip_prefix("id:") {
99 id = Some(value.trim().to_string());
100 } else if let Some(value) = line.strip_prefix("data:") {
101 if !data.is_empty() {
102 data.push('\n');
103 }
104 data.push_str(value.strip_prefix(' ').unwrap_or(value));
105 }
106 }
107 let event = event?;
108 Some(SseFrame { event, id: id.filter(|s| !s.is_empty()), data })
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn parser_handles_frames_split_across_chunks() {
117 let mut p = SseParser::new();
118 p.push(b"event: message_added\nid: m1\ndata: {\"a\"");
119 assert!(p.pop_frame().is_none(), "半帧不应产出");
120 p.push(b":1}\n\n");
121 let frame = p.pop_frame().expect("完整帧");
122 assert_eq!(frame.event, "message_added");
123 assert_eq!(frame.id.as_deref(), Some("m1"));
124 assert_eq!(frame.data, "{\"a\":1}");
125 }
126
127 #[test]
128 fn parser_handles_multibyte_split_across_chunks() {
129 let mut p = SseParser::new();
130 let full = "event: x\ndata: 好\n\n".as_bytes().to_vec();
131 let (a, b) = full.split_at(full.len() - 4);
132 p.push(a);
133 assert!(p.pop_frame().is_none());
134 p.push(b);
135 let frame = p.pop_frame().expect("完整帧");
136 assert_eq!(frame.data, "好");
137 }
138
139 #[test]
140 fn parser_flushes_trailing_frame_without_blank_line() {
141 let mut p = SseParser::new();
142 p.push(b"event: run_state\ndata: {}");
143 assert!(p.pop_frame().is_none());
144 let frame = p.flush().expect("收尾帧");
145 assert_eq!(frame.event, "run_state");
146 }
147
148 #[test]
149 fn multiple_frames_are_popped_in_order() {
150 let mut p = SseParser::new();
151 p.push(b"event: a\ndata: 1\n\nevent: b\ndata: 2\n\n");
152 assert_eq!(p.pop_frame().unwrap().event, "a");
153 assert_eq!(p.pop_frame().unwrap().event, "b");
154 assert!(p.pop_frame().is_none());
155 }
156}
Deleted lib/agent-sdk/Cargo.toml lib/agent-sdk/Cargo.toml +0 −24
@@ -1,24 +0,0 @@
1[package]
2name = "agent-sdk"
3description = "可信后端用的 Rust 客户端:/api/v1/admin/** + 会话面 + 业务面 + SSE"
4version.workspace = true
5edition.workspace = true
6rust-version = "1.85"
7publish = false
8license = "Apache-2.0"
9
10[dependencies]
11agent-types = { workspace = true }
12bytes = { workspace = true }
13futures = { workspace = true }
14reqwest = { workspace = true, features = ["stream"] }
15serde = { workspace = true, features = ["derive"] }
16serde_json = { workspace = true }
17smol_str = { workspace = true }
18thiserror = { workspace = true }
19
20[dev-dependencies]
21tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
22
23[lints.rust]
24unsafe_code = "forbid"
No newline at end of file
Deleted lib/agent-types/src/api.rs lib/agent-types/src/api.rs +0 −198
@@ -1,198 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::budget::RunLimits;
6use crate::event::AgentEvent;
7use crate::ids::{RunId, Seq, TaskId, TenantId};
8use crate::llm::TokenUsage;
9use crate::plan::Goal;
10use crate::queue::Priority;
11use crate::state::{RunState, TerminalState};
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct SubmitTaskRequest {
15 pub goal: Goal,
16 #[serde(default)]
17 pub priority: Priority,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub limits: Option<RunLimits>,
20 pub idempotency_key: SmolStr,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SubmitAccepted {
25 pub task_id: TaskId,
26 pub run_id: RunId,
27 pub state: RunState,
28 pub events_url: SmolStr,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct RunDto {
33 pub run_id: RunId,
34 pub task_id: TaskId,
35 pub tenant: TenantId,
36 pub state: RunState,
37 pub plan_revision: u32,
38 pub steps: u32,
39 pub usage: TokenUsage,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub terminal: Option<TerminalState>,
42 pub created_at_millis: i64,
43 pub updated_at_millis: i64,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct SteeringMessage {
48 pub text: SmolStr,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct CancelRequest {
53 #[serde(default)]
54 pub reason: Option<SmolStr>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ApprovalDecision {
59 pub approved: bool,
60 pub decided_by: SmolStr,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub note: Option<SmolStr>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct Page<T> {
67 pub items: Vec<T>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub next_cursor: Option<SmolStr>,
70}
71
72pub type EventPage = Page<AgentEvent>;
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum ApiErrorCode {
77 BadRequest,
78 Unauthenticated,
79 Forbidden,
80 NotFound,
81 Conflict,
82 Gone,
83 TooManyRequests,
84 NotImplemented,
85 Internal,
86 Unavailable,
87}
88
89impl ApiErrorCode {
90 #[must_use]
91 pub const fn status(self) -> u16 {
92 match self {
93 Self::BadRequest => 400,
94 Self::Unauthenticated => 401,
95 Self::Forbidden => 403,
96 Self::NotFound => 404,
97 Self::Conflict => 409,
98 Self::Gone => 410,
99 Self::TooManyRequests => 429,
100 Self::NotImplemented => 501,
101 Self::Internal => 500,
102 Self::Unavailable => 503,
103 }
104 }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct ApiError {
109 pub code: ApiErrorCode,
110 pub message: SmolStr,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub retry_after_ms: Option<u64>,
113}
114
115impl ApiError {
116 #[must_use]
117 pub fn new(code: ApiErrorCode, message: impl Into<SmolStr>) -> Self {
118 Self { code, message: message.into(), retry_after_ms: None }
119 }
120
121 #[must_use]
122 pub fn retryable(code: ApiErrorCode, message: impl Into<SmolStr>, retry_after_ms: u64) -> Self {
123 Self { code, message: message.into(), retry_after_ms: Some(retry_after_ms) }
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct SseFrame {
129 pub event: &'static str,
130 pub id: Seq,
131 pub data: String,
132}
133
134impl SseFrame {
135 #[must_use]
136 pub fn render(&self) -> String {
137 format!("event: {}\nid: {}\ndata: {}\n\n", self.event, self.id, self.data)
138 }
139}
140
141#[must_use]
142pub fn frame_of(seq: Seq, event: &AgentEvent) -> SseFrame {
143 SseFrame {
144 event: event.kind(),
145 id: seq,
146 data: serde_json::to_string(event).unwrap_or_else(|_| "{}".to_string()),
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::ids::RunId;
154
155 #[test]
156 fn sse_frame_renders_three_fields() {
157 let f = SseFrame { event: "step_started", id: 7, data: "{}".into() };
158 assert_eq!(f.render(), "event: step_started\nid: 7\ndata: {}\n\n");
159 }
160
161 #[test]
162 fn error_codes_map_to_status() {
163 assert_eq!(ApiErrorCode::TooManyRequests.status(), 429);
164 assert_eq!(ApiErrorCode::NotImplemented.status(), 501);
165 assert_eq!(ApiErrorCode::Gone.status(), 410);
166 }
167
168 #[test]
169 fn submit_request_defaults_priority_and_roundtrips() {
170 let req = SubmitTaskRequest {
171 goal: Goal {
172 text: "调研 X".into(),
173 constraints: Vec::new(),
174 inputs: Vec::new(),
175 history: Vec::new(),
176 },
177 priority: Priority::default(),
178 limits: None,
179 idempotency_key: "k-1".into(),
180 };
181 let json = serde_json::to_string(&req).unwrap();
182 let back: SubmitTaskRequest = serde_json::from_str(&json).unwrap();
183 assert_eq!(back, req);
184 }
185
186 #[test]
187 fn frame_of_uses_event_kind() {
188 let ev = AgentEvent::StepStarted {
189 run_id: RunId::new(),
190 step: 1,
191 ctx_fp: crate::ids::CtxFingerprint::digest(&[b"x"]),
192 };
193 let f = frame_of(3, &ev);
194 assert_eq!(f.event, "step_started");
195 assert_eq!(f.id, 3);
196 assert!(f.data.contains("\"kind\":\"step_started\""));
197 }
198}
Deleted lib/agent-types/src/artifact.rs lib/agent-types/src/artifact.rs +0 −104
@@ -1,104 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::{ArtifactId, RunId, StepNo, TaskId, TenantId};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ArtifactKind {
10 Workspace,
11 Screenshot,
12 Report,
13 Snapshot,
14 Dataset,
15 Code,
16 Conclusion,
17}
18
19impl ArtifactKind {
20 #[must_use]
21 pub const fn as_str(self) -> &'static str {
22 match self {
23 Self::Workspace => "workspace",
24 Self::Screenshot => "screenshot",
25 Self::Report => "report",
26 Self::Snapshot => "snapshot",
27 Self::Dataset => "dataset",
28 Self::Code => "code",
29 Self::Conclusion => "conclusion",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum ArtifactState {
37 Claimed,
38 Sealed,
39 Abandoned,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub struct Claimant {
44 pub run_id: RunId,
45 pub step: StepNo,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct ObjectRef {
50 pub bucket: SmolStr,
51 pub key: SmolStr,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub etag: Option<SmolStr>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
57pub struct ArtifactRef {
58 pub id: ArtifactId,
59 pub kind: ArtifactKind,
60 pub object: ObjectRef,
61 pub size_bytes: u64,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub checksum: Option<SmolStr>,
64}
65
66#[must_use]
67pub fn object_key(
68 tenant: TenantId,
69 task: TaskId,
70 run: RunId,
71 kind: ArtifactKind,
72 artifact: ArtifactId,
73) -> String {
74 format!("{tenant}/{task}/{run}/{}/{artifact}", kind.as_str())
75}
76
77#[must_use]
78pub fn is_orphan_claim(claimed_at_millis: i64, now_millis: i64, grace_millis: i64) -> bool {
79 now_millis.saturating_sub(claimed_at_millis) >= grace_millis
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 #[test]
87 fn object_key_follows_8_3_rule() {
88 let (t, task, run, a) = (TenantId::new(), TaskId::new(), RunId::new(), ArtifactId::new());
89 let key = object_key(t, task, run, ArtifactKind::Report, a);
90 assert_eq!(key, format!("{t}/{task}/{run}/report/{a}"));
91 assert!(key.starts_with(&t.to_string()), "租户前缀隔离(10.1 节)");
92 }
93
94 #[test]
95 fn orphan_detection_uses_grace_window() {
96 assert!(!is_orphan_claim(0, 1_000, 5_000));
97 assert!(is_orphan_claim(0, 5_000, 5_000));
98 }
99
100 #[test]
101 fn artifact_state_serde_is_snake_case() {
102 assert_eq!(serde_json::to_string(&ArtifactState::Sealed).unwrap(), "\"sealed\"");
103 }
104}
Deleted lib/agent-types/src/budget.rs lib/agent-types/src/budget.rs +0 −109
@@ -1,109 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::ids::StepNo;
5use crate::llm::TokenUsage;
6use crate::state::FailReason;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9pub struct RunLimits {
10 pub max_steps: StepNo,
11 pub max_tokens: u32,
12 pub max_wall_millis: u64,
13}
14
15impl Default for RunLimits {
16 fn default() -> Self {
17 Self { max_steps: 160, max_tokens: 2_000_000, max_wall_millis: 45 * 60 * 1000 }
18 }
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Budget {
23 pub limits: RunLimits,
24 pub steps: StepNo,
25 pub usage: TokenUsage,
26 pub started_at_millis: i64,
27}
28
29impl Budget {
30 #[must_use]
31 pub fn new(limits: RunLimits, started_at_millis: i64) -> Self {
32 Self { limits, steps: 0, usage: TokenUsage::default(), started_at_millis }
33 }
34
35 pub fn spend(&mut self, usage: TokenUsage) {
36 self.usage += usage;
37 }
38
39 pub fn record_step(&mut self) {
40 self.steps = self.steps.saturating_add(1);
41 }
42
43 #[must_use]
44 pub fn remaining_steps(&self) -> StepNo {
45 self.limits.max_steps.saturating_sub(self.steps)
46 }
47
48 #[must_use]
49 pub fn can_start_step(&self, step_budget_millis: u64, now_millis: i64) -> bool {
50 self.remaining_steps() > 0
51 && self
52 .limits
53 .max_wall_millis
54 .saturating_sub(now_millis.saturating_sub(self.started_at_millis).max(0) as u64)
55 >= step_budget_millis
56 }
57
58 #[must_use]
59 pub fn check(&self, now_millis: i64) -> Option<FailReason> {
60 if self.steps >= self.limits.max_steps {
61 return Some(FailReason::StepBudget);
62 }
63 if self.usage.total() >= self.limits.max_tokens {
64 return Some(FailReason::TokenBudget);
65 }
66 let elapsed = now_millis.saturating_sub(self.started_at_millis).max(0) as u64;
67 if elapsed >= self.limits.max_wall_millis {
68 return Some(FailReason::WallclockBudget);
69 }
70 None
71 }
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn budget() -> Budget {
79 Budget::new(RunLimits { max_steps: 3, max_tokens: 1_000, max_wall_millis: 10_000 }, 0)
80 }
81
82 #[test]
83 fn step_budget_hits_first() {
84 let mut b = budget();
85 assert_eq!(b.check(1_000), None);
86 b.record_step();
87 b.record_step();
88 b.record_step();
89 assert_eq!(b.check(1_000), Some(FailReason::StepBudget));
90 }
91
92 #[test]
93 fn token_and_wallclock_budgets_are_independent() {
94 let mut b = budget();
95 b.spend(TokenUsage { prompt: 900, completion: 200, cached: 0 });
96 assert_eq!(b.check(1_000), Some(FailReason::TokenBudget));
97 let mut b2 = budget();
98 assert_eq!(b2.check(10_000), Some(FailReason::WallclockBudget));
99 b2.record_step();
100 assert_eq!(b2.check(9_999), None);
101 }
102
103 #[test]
104 fn can_start_step_respects_remaining_wallclock() {
105 let b = budget();
106 assert!(b.can_start_step(5_000, 1_000));
107 assert!(!b.can_start_step(5_000, 9_000), "剩余墙钟不足一个 Step 时应放弃");
108 }
109}
Deleted lib/agent-types/src/consts.rs lib/agent-types/src/consts.rs +0 −66
@@ -1,66 +0,0 @@
1use std::time::Duration;
2
3pub const SNAPSHOT_INTERVAL_EVENTS: u64 = 64;
4pub const EVENTS_PER_STEP_ESTIMATE: u64 = 3;
5pub const SYSTEM_RESERVE_TOKENS: u32 = 8_192;
6pub const VERIFY_RESERVE_TOKENS: u32 = 4_096;
7pub const WORK_WINDOW_RATIO: u32 = 7;
8pub const HISTORY_WINDOW_RATIO: u32 = 3;
9pub const SOFT_COMPACT_PCT: u32 = 80;
10pub const HARD_COMPACT_PCT: u32 = 95;
11pub const LEASE_TTL: Duration = Duration::from_secs(90);
12pub const LEASE_HEARTBEAT: Duration = Duration::from_secs(30);
13pub const TENANT_CONCURRENCY_SOFT_CAP: u32 = 16;
14pub const WORKER_MAX_CONCURRENT_RUNS: u32 = 8;
15pub const SANDBOX_POOL_MIN_IDLE: u32 = 4;
16pub const SANDBOX_POOL_MAX_IDLE: u32 = 8;
17pub const SANDBOX_DEFAULT_TTL: Duration = Duration::from_secs(30 * 60);
18pub const POISON_MAX_ATTEMPTS: u32 = 3;
19pub const SANDBOX_FAILURE_ESCALATION: u32 = 3;
20pub const ARTIFACT_RETENTION_DAYS: i64 = 30;
21pub const MAX_OBJECT_BYTES: u64 = 5 * 1024 * 1024 * 1024;
22pub const LLM_STEP_TIMEOUT: Duration = Duration::from_secs(20);
23pub const STEP_WALLCLOCK_BUDGET: Duration = Duration::from_secs(25);
24/// 单个 Step 的墙钟硬上限(运行时真正中断)。
25///
26/// `STEP_WALLCLOCK_BUDGET` 只参与租约续期算式,不打断执行;而一个 Step 内 LLM
27/// 最多重试 4 次 × 20s(`LLM_CALL_DEADLINE`),再叠加工具执行,最坏可达分钟级。
28/// 没有这个上界时,一个卡住的 Step 会长期占住 worker 并发槽位。
29pub const STEP_HARD_CEILING: Duration = Duration::from_secs(300);
30pub const OBSERVATION_MAX_BYTES: usize = 8 * 1024;
31/// 孤儿判定的宽限期:投影停止推进多久之后才可以被回收器认领
32/// (同时用于产物声明对账)。
33///
34/// 必须明显大于 `LEASE_TTL`:租约过期只说明「上一个 worker 已经不行了」,
35/// 宽限期过了才说明「这个 run 确实没人管了」。
36/// 两者合并会让回收器与正在干活的 worker 抢活。
37pub const ORPHAN_CLAIM_GRACE: Duration = Duration::from_secs(120);
38/// 同一个 run 允许被孤儿回收器重新投递的次数上限。
39///
40/// 没有这个上界时,一个「每次被认领就在写第一条事件前崩掉」的 run 会被回收器
41/// 无限重投,把队列变成永动机。超过上限即判定为坏 run,落终态交人工排查。
42pub const ORPHAN_RECLAIM_MAX_ATTEMPTS: u32 = 3;
43pub const LOCAL_BUCKET: &str = "agent-local";
44pub const SSE_BROADCAST_CAPACITY: usize = 4096;
45pub const LLM_CALL_DEADLINE: Duration = Duration::from_secs(20);
46pub const COLLECT_MAX_FILES: usize = 256;
47pub const SKILL_BODY_MAX_BYTES: usize = 32 * 1024;
48pub const SKILL_SYNC_MAX_FILES: usize = COLLECT_MAX_FILES;
49pub const SKILL_SYNC_MAX_BYTES: u64 = 8 * 1024 * 1024;
50pub const SKILL_SYNC_FILE_MAX_BYTES: u64 = 1024 * 1024;
51pub const SKILL_DIR_NAME: &str = "SKILL.md";
52pub const SKILL_WORKSPACE_ROOT: &str = ".skills";
53pub const DEFAULT_SKILLS_DIR: &str = "/opt/skills";
54
55/// 决策模型(TypeSafe System One)默认模型名。该接口一次前向返回
56/// 分类/评分/是非判断及其概率与置信度,不生成文本。
57pub const DECISION_MODEL_DEFAULT: &str = "decision-model-preview";
58/// 决策模型单次调用墙钟上限:它在审批超时循环与 Agent 工具循环里都会被调用,
59/// 拿不到结论就应当回落原处置,而不是把主循环拖住。
60pub const DECISION_CALL_TIMEOUT: Duration = Duration::from_secs(8);
61/// 审批超时仲裁的放行置信度下限:低于它一律维持「超时自动拒绝」。
62pub const APPROVAL_ARBITER_MIN_CONFIDENCE: f64 = 0.90;
63/// 危害期望上限(score 等级的概率加权期望,0 起):超过即拒。
64pub const APPROVAL_ARBITER_MAX_SEVERITY: f64 = 1.0;
65/// 不可逆损害概率上限(noul P(yes)):超过即拒。
66pub const APPROVAL_ARBITER_MAX_RISKY: f64 = 0.20;
Deleted lib/agent-types/src/context.rs lib/agent-types/src/context.rs +0 −425
@@ -1,425 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::consts;
5use crate::event::AgentEvent;
6use crate::event::CompactionTier;
7use crate::ids::CtxFingerprint;
8use crate::llm::{ChatMessage, ChatRole};
9
10#[must_use]
11pub fn estimate_tokens(text: &str) -> u32 {
12 let bytes = text.len();
13 if bytes == 0 {
14 return 0;
15 }
16 bytes.div_ceil(4) as u32
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
20pub struct WindowBudget {
21 pub total: u32,
22 pub system_reserve: u32,
23 pub verify_reserve: u32,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub struct WindowSplit {
28 pub work: u32,
29 pub history: u32,
30}
31
32impl WindowBudget {
33 #[must_use]
34 pub fn new(total: u32) -> Self {
35 Self {
36 total,
37 system_reserve: consts::SYSTEM_RESERVE_TOKENS,
38 verify_reserve: consts::VERIFY_RESERVE_TOKENS,
39 }
40 }
41
42 #[must_use]
43 pub fn split(&self) -> WindowSplit {
44 let free =
45 self.total.saturating_sub(self.system_reserve).saturating_sub(self.verify_reserve);
46 let parts = consts::WORK_WINDOW_RATIO + consts::HISTORY_WINDOW_RATIO;
47 let work = free / parts * consts::WORK_WINDOW_RATIO;
48 WindowSplit { work, history: free - work }
49 }
50}
51
52#[must_use]
53pub fn should_compact(used: u32, of: u32) -> Option<CompactionTier> {
54 if of == 0 {
55 return None;
56 }
57 let pct = used.saturating_mul(100) / of;
58 if pct >= consts::HARD_COMPACT_PCT {
59 Some(CompactionTier::Hard)
60 } else if pct >= consts::SOFT_COMPACT_PCT {
61 Some(CompactionTier::Soft)
62 } else {
63 None
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
68pub struct ContextWindow {
69 pub system: Vec<ChatMessage>,
70 pub history: Vec<ChatMessage>,
71 pub work: Vec<ChatMessage>,
72 pub compactions: u32,
73}
74
75const fn role_tag(role: ChatRole) -> &'static str {
76 match role {
77 ChatRole::System => "system",
78 ChatRole::User => "user",
79 ChatRole::Assistant => "assistant",
80 ChatRole::Tool => "tool",
81 }
82}
83
84#[must_use]
85pub fn render_actions(step: crate::ids::StepNo, actions: &[crate::event::AgentAction]) -> String {
86 let mut text = String::new();
87 for a in actions {
88 match a {
89 crate::event::AgentAction::ToolCall { call } => {
90 text.push_str(&format!("step {step} call {} ", call.name));
91 }
92 crate::event::AgentAction::Finish { summary } => {
93 text.push_str(&format!("finish: {summary}"));
94 }
95 crate::event::AgentAction::UpdatePlan { note, .. } => {
96 text.push_str(&format!("plan: {note} "));
97 }
98 crate::event::AgentAction::AckUserMessage { at_seq } => {
99 text.push_str(&format!("ack seq{at_seq} "));
100 }
101 }
102 }
103 text
104}
105
106/// 技能清单块:常驻系统提示,只列 name/description,正文按需加载。
107#[must_use]
108pub fn render_skill_manifest(skills: &[crate::skill::SkillEntry]) -> String {
109 let mut out = String::from(
110 "可用技能(按需加载:调用 skill.load 并传入技能名即可注入正文;配套文件位于 /workspace/.skills/<name>/):\n",
111 );
112 for skill in skills {
113 out.push_str(&format!("- {}: {}\n", skill.name, skill.description.trim()));
114 }
115 out
116}
117
118/// 技能正文块:正文以事件内联,重放时不再读取本机文件。
119#[must_use]
120pub fn render_skill_body(name: &str, hash: &str, body: &str) -> String {
121 format!("【技能 {name} 正文({hash})】\n{body}")
122}
123
124/// 技能不可用提示块:缺失 / 哈希不符 / 超预算 / 未在清单中。
125#[must_use]
126pub fn render_skill_rejected(name: &str, reason: &str) -> String {
127 format!("【技能 {name} 不可用】{reason}")
128}
129
130#[must_use]
131pub fn render_outcome(outcome: &crate::tool::ToolOutcome) -> String {
132 use crate::tool::ToolOutcome;
133 match outcome {
134 ToolOutcome::Ok { output, .. } => output.observation.to_string(),
135 ToolOutcome::Err { code, message, .. } => format!("{code:?} {message}"),
136 ToolOutcome::Timeout { limit_ms } => format!("timeout after {limit_ms}ms"),
137 // A-4:中断意味着「副作用状态未知」——进程在执行中途崩溃,外部世界
138 // 可能已经发生了变化。必须引导模型先验证再重试,而不是当普通失败重发
139 // (重复副作用是 agent 自评错误率的重灾区,人侧只看见「链完整、全绿」)。
140 ToolOutcome::Interrupted => {
141 "interrupted: 执行进程在调用中途崩溃,副作用状态未知。".to_string()
142 }
143 }
144}
145
146impl ContextWindow {
147 #[must_use]
148 pub fn messages(&self) -> Vec<ChatMessage> {
149 let mut out = Vec::with_capacity(self.system.len() + self.history.len() + self.work.len());
150 out.extend(self.system.iter().cloned());
151 out.extend(self.history.iter().cloned());
152 out.extend(self.work.iter().cloned());
153 out
154 }
155
156 #[must_use]
157 pub fn work_tokens(&self) -> u32 {
158 self.work.iter().map(ChatMessage::estimate_tokens).sum()
159 }
160
161 #[must_use]
162 pub fn history_tokens(&self) -> u32 {
163 self.history.iter().map(ChatMessage::estimate_tokens).sum()
164 }
165
166 #[must_use]
167 pub fn fingerprint(&self) -> CtxFingerprint {
168 let mut buf: Vec<u8> = Vec::with_capacity(256);
169 for group in [&self.system, &self.history, &self.work] {
170 buf.extend_from_slice(b"#group\n");
171 for m in group {
172 buf.extend_from_slice(role_tag(m.role).as_bytes());
173 buf.push(b'|');
174 buf.extend_from_slice(m.content.as_bytes());
175 buf.push(b'\n');
176 }
177 }
178 CtxFingerprint::digest(&[&buf])
179 }
180
181 #[must_use]
182 pub fn trimmed_work(&self, budget: u32) -> Vec<ChatMessage> {
183 let mut keep: Vec<ChatMessage> = Vec::new();
184 let mut used = 0u32;
185 for msg in self.work.iter().rev() {
186 let cost = msg.estimate_tokens();
187 if !keep.is_empty() && used.saturating_add(cost) > budget {
188 break;
189 }
190 used = used.saturating_add(cost);
191 keep.push(msg.clone());
192 }
193 keep.reverse();
194 keep
195 }
196}
197
198#[must_use]
199pub fn rebuild(
200 events: &[AgentEvent],
201 system: Vec<ChatMessage>,
202 budget: &WindowBudget,
203) -> ContextWindow {
204 rebuild_from(&ContextWindow { system, ..Default::default() }, events, budget)
205}
206
207#[must_use]
208pub fn rebuild_from(
209 base: &ContextWindow,
210 events: &[AgentEvent],
211 budget: &WindowBudget,
212) -> ContextWindow {
213 use crate::plan::ItemState;
214 let mut win = base.clone();
215 for ev in events {
216 match ev {
217 AgentEvent::PlanAdopted { plan, .. } | AgentEvent::PlanRevised { plan, .. } => {
218 let done = plan.items.iter().filter(|i| i.done == ItemState::Done).count();
219 win.history.push(ChatMessage::system(format!(
220 "plan rev{} {}/{} items done",
221 plan.revision,
222 done,
223 plan.items.len()
224 )));
225 }
226 AgentEvent::LlmCompleted { actions, step, .. } => {
227 let text = render_actions(*step, actions);
228 if !text.is_empty() {
229 win.work.push(ChatMessage::new(ChatRole::Assistant, text));
230 }
231 }
232 AgentEvent::ToolCallFinished { outcome, call_id, .. } => {
233 win.work.push(ChatMessage {
234 role: ChatRole::Tool,
235 content: render_outcome(outcome),
236 call_id: Some(*call_id),
237 name: None,
238 });
239 }
240 AgentEvent::UserMessageInjected { text, .. } => {
241 win.work.push(ChatMessage::new(ChatRole::User, text.to_string()));
242 }
243 AgentEvent::SkillManifestLoaded { skills, .. } => {
244 win.system.push(ChatMessage::system(render_skill_manifest(skills)));
245 }
246 AgentEvent::SkillLoaded { name, hash, body, .. } => {
247 win.system.push(ChatMessage::system(render_skill_body(name, hash, body)));
248 }
249 AgentEvent::SkillRejected { name, reason, .. } => {
250 win.system.push(ChatMessage::system(render_skill_rejected(name, reason)));
251 }
252 AgentEvent::ContextCompacted { summary, .. } => {
253 win.history.push(summary.clone());
254 win.work.clear();
255 win.compactions += 1;
256 }
257 _ => {}
258 }
259 }
260 let split = budget.split();
261 if win.work_tokens() > split.work {
262 win.work = win.trimmed_work(split.work);
263 }
264 win
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::event::AgentAction;
271 use crate::ids::{CallId, RunId};
272 use crate::llm::TokenUsage;
273 use crate::tool::{ToolOutcome, ToolOutput};
274
275 fn assistant(run: RunId, step: u32, payload: &str) -> AgentEvent {
276 AgentEvent::LlmCompleted {
277 run_id: run,
278 step,
279 model: crate::llm::ModelId::new("fast"),
280 degraded: false,
281 usage: TokenUsage::default(),
282 actions: vec![AgentAction::Finish { summary: payload.into() }],
283 }
284 }
285
286 fn tool_done(run: RunId, payload: &str) -> AgentEvent {
287 AgentEvent::ToolCallFinished {
288 run_id: run,
289 call_id: CallId::new(),
290 outcome: ToolOutcome::Ok {
291 exit_code: 0,
292 output: ToolOutput {
293 observation: payload.into(),
294 artifacts: Vec::new(),
295 truncated: false,
296 exit_code: 0,
297 },
298 duration_ms: 10,
299 },
300 }
301 }
302
303 #[test]
304 fn token_estimate_is_zero_safe() {
305 assert_eq!(estimate_tokens(""), 0);
306 assert_eq!(estimate_tokens("abcd"), 1);
307 assert_eq!(estimate_tokens("abcde"), 2);
308 }
309
310 #[test]
311 fn split_follows_7_to_3_after_reserves() {
312 let b = WindowBudget::new(128_000);
313 let s = b.split();
314 let free = 128_000 - consts::SYSTEM_RESERVE_TOKENS - consts::VERIFY_RESERVE_TOKENS;
315 assert_eq!(s.work + s.history, free);
316 assert!(s.work > s.history);
317 }
318
319 #[test]
320 fn compaction_tiers_match_thresholds() {
321 assert_eq!(should_compact(79, 100), None);
322 assert_eq!(should_compact(80, 100), Some(CompactionTier::Soft));
323 assert_eq!(should_compact(94, 100), Some(CompactionTier::Soft));
324 assert_eq!(should_compact(95, 100), Some(CompactionTier::Hard));
325 }
326
327 #[test]
328 fn rebuild_is_deterministic() {
329 let run = RunId::new();
330 let events = vec![assistant(run, 1, "a"), tool_done(run, "obs"), assistant(run, 2, "b")];
331 let b = WindowBudget::new(128_000);
332 let w1 = rebuild(&events, vec![ChatMessage::system("goal")], &b);
333 let w2 = rebuild(&events, vec![ChatMessage::system("goal")], &b);
334 assert_eq!(w1, w2);
335 assert_eq!(w1.fingerprint(), w2.fingerprint());
336 assert_eq!(w1.messages().len(), 1 + 3);
337 }
338
339 #[test]
340 fn persisted_summary_replaces_regeneration() {
341 let run = RunId::new();
342 let events = vec![
343 assistant(run, 1, "old"),
344 AgentEvent::ContextCompacted {
345 run_id: run,
346 from_step: 1,
347 to_step: 1,
348 tier: CompactionTier::Soft,
349 summary: ChatMessage::system("SUMMARY: 已抓取 3 页"),
350 fp_before: CtxFingerprint::digest(&[b"a"]),
351 fp_after: CtxFingerprint::digest(&[b"b"]),
352 usage: TokenUsage::default(),
353 },
354 assistant(run, 2, "new"),
355 ];
356 let w = rebuild(&events, Vec::new(), &WindowBudget::new(128_000));
357 assert_eq!(w.compactions, 1);
358 assert_eq!(w.history.len(), 1, "摘要进入历史区");
359 assert!(w.history[0].content.contains("已抓取 3 页"));
360 assert_eq!(w.work.len(), 1, "压缩前的工作区被清空");
361 }
362
363 #[test]
364 fn skill_events_render_into_the_system_group_in_order() {
365 let run = RunId::new();
366 let events = vec![
367 AgentEvent::SkillManifestLoaded {
368 run_id: run,
369 skills: vec![crate::skill::SkillEntry::new("demo", "演示技能", "sha256:aa")],
370 },
371 AgentEvent::SkillLoaded {
372 run_id: run,
373 step: 1,
374 name: "demo".into(),
375 hash: "sha256:aa".into(),
376 size_bytes: 5,
377 body: "BODY!".into(),
378 },
379 AgentEvent::SkillRejected {
380 run_id: run,
381 step: 1,
382 name: "gone".into(),
383 reason: "技能文件不存在".into(),
384 },
385 ];
386 let base = vec![ChatMessage::system("base")];
387 let w = rebuild(&events, base.clone(), &WindowBudget::new(128_000));
388 assert_eq!(w.system.len(), 4, "基础提示 + 清单 + 正文 + 拒绝提示");
389 assert_eq!(w.system[0].content, "base");
390 assert!(w.system[1].content.contains("可用技能"), "{:?}", w.system[1]);
391 assert!(w.system[1].content.contains("- demo: 演示技能"));
392 assert!(w.system[2].content.contains("【技能 demo 正文(sha256:aa)】"));
393 assert!(w.system[2].content.contains("BODY!"));
394 assert!(w.system[3].content.contains("【技能 gone 不可用】技能文件不存在"));
395 assert_eq!(w.work.len(), 0, "技能内容不进工作区");
396
397 // 重放确定性:同一事件序列必须得到同一指纹,且与分批重建一致。
398 let full = rebuild(&events, base.clone(), &WindowBudget::new(128_000));
399 let mut stepwise = ContextWindow { system: base, ..Default::default() };
400 for ev in &events {
401 stepwise =
402 rebuild_from(&stepwise, std::slice::from_ref(ev), &WindowBudget::new(128_000));
403 }
404 assert_eq!(full, stepwise);
405 assert_eq!(full.fingerprint(), w.fingerprint());
406 }
407
408 #[test]
409 fn empty_skill_manifest_renders_a_short_listing() {
410 let text = render_skill_manifest(&[]);
411 assert!(text.starts_with("可用技能"), "{text}");
412 assert!(!text.contains("\n- "), "空清单不得列出条目:{text}");
413 }
414
415 #[test]
416 fn trimming_keeps_newest_observations() {
417 let run = RunId::new();
418 let tight = WindowBudget { total: 4_096, system_reserve: 2_048, verify_reserve: 2_048 };
419 let events: Vec<AgentEvent> =
420 (0..40).map(|i| tool_done(run, &format!("obs-{i}-{}", "x".repeat(400)))).collect();
421 let w = rebuild(&events, Vec::new(), &tight);
422 assert!(w.work.len() < events.len(), "工作区必须被裁剪");
423 assert!(w.work.last().expect("非空").content.starts_with("obs-39"), "必须保留最新观察");
424 }
425}
Deleted lib/agent-types/src/decision.rs lib/agent-types/src/decision.rs +0 −62
@@ -1,62 +0,0 @@
1//! 审批超时仲裁的抽象契约。
2//!
3//! 为什么放在 types(最底层):决策模型的**实现**在 `agent-llm`,**消费方**是
4//! `run-manager` 的审批 TTL 扫描,而依赖方向(docs/03 §3.8)不允许 run-manager
5//! 反向依赖 llm。把契约抽到 types,两边各自依赖抽象,组装在 agent-runtime 完成。
6//!
7//! 语义边界:arbiter 只在**人已超时未裁决**时替代「盲目拒绝」这一个动作。
8//! 它不引入新的信任面——拿不到结论一律回落原处置(`Skip`)。
9use async_trait::async_trait;
10use smol_str::SmolStr;
11
12use crate::ids::{CallId, RunId, TenantId};
13use crate::tool::RiskLevel;
14
15/// 一次待仲裁的超时审批。
16///
17/// `args` 是**脱敏后**的参数副本:决策模型不需要明文密钥就能判断「这个动作该不该
18/// 放行」,把原文发给外部端点等于又开一条泄密通道(与事件流写脱敏副本同一口径)。
19#[derive(Debug, Clone, PartialEq)]
20pub struct ApprovalCase {
21 pub run_id: RunId,
22 pub call_id: CallId,
23 pub tenant: TenantId,
24 pub tool: SmolStr,
25 pub risk: RiskLevel,
26 /// 停车时写给运维/用户的理由(含风险等级与副作用分类)。
27 pub reason: SmolStr,
28 pub args: serde_json::Value,
29 /// 已等待时长(秒)。
30 pub waited_secs: i64,
31 /// run 的目标文本:判断「放行是否与任务一致」的唯一依据。
32 pub goal: SmolStr,
33}
34
35/// 仲裁结论。
36#[derive(Debug, Clone, PartialEq)]
37pub enum ArbiterVerdict {
38 /// 放行。`confidence` 与 `rationale` 会落进 `ApprovalDecided` 的 note,
39 /// 保证「机器替人做的决定」在事件流里可审计、可复盘。
40 Approve { confidence: f64, rationale: SmolStr },
41 /// 拒绝(与 TTL 自动拒绝同一条路径,只是理由换成模型给的)。
42 Deny { confidence: f64, rationale: SmolStr },
43 /// 未配置/不可用/结论不足:调用方保持原有处置(超时自动拒绝)。
44 /// 这是 fail-closed 的落点:宁可不判,也不能把「调用失败」读成「同意」。
45 Skip,
46}
47
48#[async_trait]
49pub trait ApprovalArbiter: Send + Sync {
50 /// 对一次超时审批给出处置建议。实现必须自行保证不 panic、不阻塞超过配置上限。
51 async fn verdict(&self, case: &ApprovalCase) -> ArbiterVerdict;
52}
53
54/// 不仲裁的默认实现(保持 A-5 原语义:超时即拒绝)。
55pub struct NoArbiter;
56
57#[async_trait]
58impl ApprovalArbiter for NoArbiter {
59 async fn verdict(&self, _case: &ApprovalCase) -> ArbiterVerdict {
60 ArbiterVerdict::Skip
61 }
62}
Deleted lib/agent-types/src/event.rs lib/agent-types/src/event.rs +0 −398
@@ -1,398 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::{ArtifactKind, ArtifactRef};
6use crate::ids::{
7 ArtifactId, CallId, CtxFingerprint, RunId, Seq, SessionId, StepNo, TaskId, TenantId,
8};
9use crate::llm::{ChatMessage, ModelId, TokenUsage};
10use crate::plan::{Plan, ReplanReason};
11use crate::skill::SkillEntry;
12use crate::state::{FailReason, RunState, TerminalState, Transition};
13use crate::tool::{RiskLevel, ToolCallSpec, ToolOutcome};
14
15/// 当前事件负载的 schema 版本。
16///
17/// 哈希链保证「不可篡改」,不保证「可兼容重放」:字段增删后的旧事件流能否
18/// 被新代码解释,需要显式版本号来回答。规则:
19/// - 新写入的 [`AgentEvent::RunStarted`] 携带当前版本;
20/// - 缺省(旧事件流)反序列化为 `0`,按旧语义容忍;
21/// - 版本高于当前支持上限时,恢复层必须拒绝(fail-closed),
22/// 而不是靠 serde 宽容分支静默丢字段。
23pub const EVENT_SCHEMA_VERSION: u32 = 1;
24
25/// 历史事件流里能容忍的最大版本(等于当前写入版本)。
26pub const MAX_SUPPORTED_EVENT_SCHEMA_VERSION: u32 = EVENT_SCHEMA_VERSION;
27
28#[must_use]
29fn default_event_schema_version() -> u32 {
30 0
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(tag = "kind", rename_all = "snake_case")]
35pub enum AgentAction {
36 ToolCall { call: ToolCallSpec },
37 UpdatePlan { plan: Plan, note: SmolStr },
38 AckUserMessage { at_seq: Seq },
39 Finish { summary: SmolStr },
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43#[serde(rename_all = "snake_case")]
44pub enum CompactionTier {
45 Soft,
46 Hard,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum AgentEvent {
52 RunStarted {
53 run_id: RunId,
54 task_id: TaskId,
55 tenant: TenantId,
56 goal: crate::plan::Goal,
57 limits: crate::budget::RunLimits,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 session_id: Option<SessionId>,
60 /// 事件 schema 版本:旧流缺省为 0,新写入为
61 /// [`EVENT_SCHEMA_VERSION`]。见该常量的文档。
62 #[serde(default = "default_event_schema_version", skip_serializing_if = "is_zero")]
63 schema_version: u32,
64 },
65 StateEntered {
66 run_id: RunId,
67 from: RunState,
68 to: RunState,
69 trigger: Transition,
70 },
71 PlanAdopted {
72 run_id: RunId,
73 plan: Plan,
74 },
75 PlanRevised {
76 run_id: RunId,
77 plan: Plan,
78 reason: ReplanReason,
79 },
80 StepStarted {
81 run_id: RunId,
82 step: StepNo,
83 ctx_fp: CtxFingerprint,
84 },
85 LlmCompleted {
86 run_id: RunId,
87 step: StepNo,
88 model: ModelId,
89 #[serde(default)]
90 degraded: bool,
91 usage: TokenUsage,
92 actions: Vec<AgentAction>,
93 },
94 ToolCallStarted {
95 run_id: RunId,
96 step: StepNo,
97 call_id: CallId,
98 tool: SmolStr,
99 args: serde_json::Value,
100 },
101 ToolCallFinished {
102 run_id: RunId,
103 call_id: CallId,
104 outcome: ToolOutcome,
105 },
106 ApprovalRequested {
107 run_id: RunId,
108 call_id: CallId,
109 tool: SmolStr,
110 risk: RiskLevel,
111 reason: SmolStr,
112 },
113 ApprovalDecided {
114 run_id: RunId,
115 call_id: CallId,
116 approved: bool,
117 decided_by: SmolStr,
118 /// 裁决理由(人工填的 or 审批超时仲裁机填的「为何放行/拒绝」)。
119 /// 机器代 Humans 放行时,这个字段是事件流里唯一的复盘入口,
120 /// 所以 decided_by=decision-model 的条目必须带 confidence/request_id。
121 ///
122 /// 新增的可选字段:旧事件反序列化→`None`,新事件被旧读者忽略→不崩,
123 /// 两个方向都兼容,因此**不**上升 `EVENT_SCHEMA_VERSION`(它只给
124 /// 不兼容变更保留)。
125 #[serde(default)]
126 note: Option<SmolStr>,
127 },
128 ContextCompacted {
129 run_id: RunId,
130 from_step: StepNo,
131 to_step: StepNo,
132 tier: CompactionTier,
133 summary: ChatMessage,
134 fp_before: CtxFingerprint,
135 fp_after: CtxFingerprint,
136 usage: TokenUsage,
137 },
138 CancelRequested {
139 run_id: RunId,
140 reason: SmolStr,
141 },
142 PlanItemUpdated {
143 run_id: RunId,
144 idx: usize,
145 state: crate::plan::ItemState,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 evidence: Option<ArtifactRef>,
148 },
149 UserMessageInjected {
150 run_id: RunId,
151 text: SmolStr,
152 },
153 ArtifactClaimed {
154 run_id: RunId,
155 step: StepNo,
156 artifact_id: ArtifactId,
157 artifact_kind: ArtifactKind,
158 path: SmolStr,
159 },
160 ArtifactSealed {
161 run_id: RunId,
162 artifact_id: ArtifactId,
163 artifact: ArtifactRef,
164 },
165 ArtifactAbandoned {
166 run_id: RunId,
167 artifact_id: ArtifactId,
168 reason: SmolStr,
169 },
170 SkillManifestLoaded {
171 run_id: RunId,
172 skills: Vec<SkillEntry>,
173 },
174 SkillLoaded {
175 run_id: RunId,
176 step: StepNo,
177 name: SmolStr,
178 hash: SmolStr,
179 size_bytes: u64,
180 body: String,
181 },
182 SkillRejected {
183 run_id: RunId,
184 step: StepNo,
185 name: SmolStr,
186 reason: SmolStr,
187 },
188 SnapshotTaken {
189 run_id: RunId,
190 seq: Seq,
191 fp: CtxFingerprint,
192 },
193 LeaseReleased {
194 run_id: RunId,
195 worker: crate::ids::WorkerId,
196 reason: SmolStr,
197 },
198 RunFinished {
199 run_id: RunId,
200 terminal: TerminalState,
201 usage: TokenUsage,
202 steps: StepNo,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 summary: Option<SmolStr>,
205 },
206}
207
208#[must_use]
209fn is_zero(v: &u32) -> bool {
210 *v == 0
211}
212
213impl AgentEvent {
214 /// 事件流第一个 `run_started` 携带的 schema 版本(无则 0,旧流)。
215 #[must_use]
216 pub fn schema_version_of(events: &[AgentEvent]) -> u32 {
217 events
218 .iter()
219 .find_map(|ev| match ev {
220 AgentEvent::RunStarted { schema_version, .. } => Some(*schema_version),
221 _ => None,
222 })
223 .unwrap_or(0)
224 }
225
226 #[must_use]
227 pub fn kind(&self) -> &'static str {
228 match self {
229 Self::RunStarted { .. } => "run_started",
230 Self::StateEntered { .. } => "state_entered",
231 Self::PlanAdopted { .. } => "plan_adopted",
232 Self::PlanRevised { .. } => "plan_revised",
233 Self::StepStarted { .. } => "step_started",
234 Self::LlmCompleted { .. } => "llm_completed",
235 Self::ToolCallStarted { .. } => "tool_call_started",
236 Self::ToolCallFinished { .. } => "tool_call_finished",
237 Self::ApprovalRequested { .. } => "approval_requested",
238 Self::ApprovalDecided { .. } => "approval_decided",
239 Self::ContextCompacted { .. } => "context_compacted",
240 Self::UserMessageInjected { .. } => "user_message_injected",
241 Self::CancelRequested { .. } => "cancel_requested",
242 Self::PlanItemUpdated { .. } => "plan_item_updated",
243 Self::ArtifactClaimed { .. } => "artifact_claimed",
244 Self::ArtifactSealed { .. } => "artifact_sealed",
245 Self::ArtifactAbandoned { .. } => "artifact_abandoned",
246 Self::SkillManifestLoaded { .. } => "skill_manifest_loaded",
247 Self::SkillLoaded { .. } => "skill_loaded",
248 Self::SkillRejected { .. } => "skill_rejected",
249 Self::SnapshotTaken { .. } => "snapshot_taken",
250 Self::LeaseReleased { .. } => "lease_released",
251 Self::RunFinished { .. } => "run_finished",
252 }
253 }
254
255 #[must_use]
256 pub fn run_id(&self) -> RunId {
257 match self {
258 Self::RunStarted { run_id, .. }
259 | Self::StateEntered { run_id, .. }
260 | Self::PlanAdopted { run_id, .. }
261 | Self::PlanRevised { run_id, .. }
262 | Self::StepStarted { run_id, .. }
263 | Self::LlmCompleted { run_id, .. }
264 | Self::ToolCallStarted { run_id, .. }
265 | Self::ToolCallFinished { run_id, .. }
266 | Self::ApprovalRequested { run_id, .. }
267 | Self::ApprovalDecided { run_id, .. }
268 | Self::ContextCompacted { run_id, .. }
269 | Self::UserMessageInjected { run_id, .. }
270 | Self::CancelRequested { run_id, .. }
271 | Self::PlanItemUpdated { run_id, .. }
272 | Self::ArtifactClaimed { run_id, .. }
273 | Self::ArtifactSealed { run_id, .. }
274 | Self::ArtifactAbandoned { run_id, .. }
275 | Self::SkillManifestLoaded { run_id, .. }
276 | Self::SkillLoaded { run_id, .. }
277 | Self::SkillRejected { run_id, .. }
278 | Self::SnapshotTaken { run_id, .. }
279 | Self::LeaseReleased { run_id, .. }
280 | Self::RunFinished { run_id, .. } => *run_id,
281 }
282 }
283
284 #[must_use]
285 pub fn step_started(run_id: RunId, step: StepNo, ctx_fp: CtxFingerprint) -> Self {
286 Self::StepStarted { run_id, step, ctx_fp }
287 }
288
289 #[must_use]
290 pub fn state_entered(run_id: RunId, from: RunState, trigger: Transition) -> Option<Self> {
291 crate::state::next_state(from, trigger)
292 .map(|to| Self::StateEntered { run_id, from, to, trigger })
293 .ok()
294 }
295
296 #[must_use]
297 pub fn failed(run_id: RunId, reason: FailReason, usage: TokenUsage, steps: StepNo) -> Self {
298 Self::RunFinished {
299 run_id,
300 terminal: TerminalState::Failed { reason },
301 usage,
302 steps,
303 summary: None,
304 }
305 }
306}
307
308impl AgentEvent {
309 #[must_use]
310 pub fn is_terminal(&self) -> bool {
311 matches!(self, Self::RunFinished { .. })
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
316pub struct EventEnvelope {
317 pub run_id: RunId,
318 pub seq: Seq,
319 pub event: AgentEvent,
320 pub occurred_at_millis: i64,
321 pub prev_hash: Option<crate::ids::EventHash>,
322 pub hash: crate::ids::EventHash,
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use crate::plan::Goal;
329
330 #[test]
331 fn serde_roundtrip_preserves_variants() {
332 let ev = AgentEvent::RunStarted {
333 run_id: RunId::new(),
334 task_id: TaskId::new(),
335 tenant: TenantId::new(),
336 goal: Goal {
337 text: "调研 X 并产出报告".into(),
338 constraints: vec!["不超过 20 个来源".into()],
339 inputs: Vec::new(),
340 history: Vec::new(),
341 },
342 limits: crate::budget::RunLimits::default(),
343 session_id: None,
344 schema_version: EVENT_SCHEMA_VERSION,
345 };
346 let json = serde_json::to_string(&ev).unwrap();
347 assert!(json.contains("\"kind\":\"run_started\""), "{json}");
348 let back: AgentEvent = serde_json::from_str(&json).unwrap();
349 assert_eq!(back, ev);
350 assert_eq!(back.run_id(), ev.run_id());
351 }
352
353 #[test]
354 fn legacy_run_started_without_schema_version_deserializes_as_zero() {
355 let legacy = r#"{
356 "kind": "run_started",
357 "run_id": "0f4d2f10-8e58-4a90-9f52-6b13d5470001",
358 "task_id": "0f4d2f10-8e58-4a90-9f52-6b13d5470002",
359 "tenant": "0f4d2f10-8e58-4a90-9f52-6b13d5470003",
360 "goal": {"text": "g", "constraints": [], "inputs": [], "history": []},
361 "limits": {"max_steps": 160, "max_tokens": 2000000, "max_wall_millis": 2700000}
362 }"#;
363 let ev: AgentEvent = serde_json::from_str(legacy).unwrap();
364 assert_eq!(AgentEvent::schema_version_of(&[ev]), 0);
365 }
366
367 #[test]
368 fn schema_version_of_reads_the_first_run_started() {
369 let ev = AgentEvent::RunStarted {
370 run_id: RunId::new(),
371 task_id: TaskId::new(),
372 tenant: TenantId::new(),
373 goal: Goal {
374 text: "g".into(),
375 constraints: Vec::new(),
376 inputs: Vec::new(),
377 history: Vec::new(),
378 },
379 limits: crate::budget::RunLimits::default(),
380 session_id: None,
381 schema_version: EVENT_SCHEMA_VERSION,
382 };
383 assert_eq!(AgentEvent::schema_version_of(&[ev]), EVENT_SCHEMA_VERSION);
384 assert_eq!(AgentEvent::schema_version_of(&[]), 0);
385 }
386
387 #[test]
388 fn state_entered_helper_rejects_illegal_edges() {
389 assert!(
390 AgentEvent::state_entered(RunId::new(), RunState::Completed, Transition::Cancel)
391 .is_none()
392 );
393 let ev =
394 AgentEvent::state_entered(RunId::new(), RunState::Pending, Transition::ClaimedFresh)
395 .expect("legal");
396 assert_eq!(ev.kind(), "state_entered");
397 }
398}
Deleted lib/agent-types/src/ids.rs lib/agent-types/src/ids.rs +0 −250
@@ -1,250 +0,0 @@
1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use sha2::Digest as _;
6use sha2::Sha256;
7use uuid::Uuid;
8
9macro_rules! uuid_id {
10 ($(#[$doc:meta])* $name:ident) => {
11 $(#[$doc])*
12 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13 #[serde(transparent)]
14 pub struct $name(Uuid);
15
16 impl $name {
17 #[must_use]
18 pub fn new() -> Self {
19 Self(Uuid::now_v7())
20 }
21
22 #[must_use]
23 pub const fn from_uuid(id: Uuid) -> Self {
24 Self(id)
25 }
26
27 #[must_use]
28 pub const fn as_uuid(&self) -> Uuid {
29 self.0
30 }
31 }
32
33 impl Default for $name {
34 fn default() -> Self {
35 Self::new()
36 }
37 }
38
39 impl fmt::Display for $name {
40 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41 write!(f, "{}", self.0)
42 }
43 }
44
45 impl FromStr for $name {
46 type Err = uuid::Error;
47
48 fn from_str(s: &str) -> Result<Self, Self::Err> {
49 Ok(Self(Uuid::parse_str(s)?))
50 }
51 }
52 };
53}
54
55uuid_id! {
56 TaskId
57}
58uuid_id! {
59 RunId
60}
61uuid_id! {
62 TenantId
63}
64uuid_id! {
65 PlanId
66}
67uuid_id! {
68 CallId
69}
70uuid_id! {
71 MsgId
72}
73uuid_id! {
74 WorkerId
75}
76uuid_id! {
77 ArtifactId
78}
79
80uuid_id! {
81 SessionId
82}
83
84uuid_id! {
85 MessageId
86}
87
88uuid_id! {
89 GrantId
90}
91
92pub type Seq = u64;
93pub type StepNo = u32;
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
96pub struct CtxFingerprint(pub [u8; 8]);
97
98impl CtxFingerprint {
99 #[must_use]
100 pub fn digest(parts: &[&[u8]]) -> Self {
101 let mut h = Sha256::new();
102 for p in parts {
103 h.update((p.len() as u64).to_le_bytes());
104 h.update(p);
105 }
106 let out: [u8; 32] = h.finalize().into();
107 let mut fp = [0u8; 8];
108 fp.copy_from_slice(&out[..8]);
109 Self(fp)
110 }
111
112 #[must_use]
113 pub fn as_hex(&self) -> String {
114 hex::encode(self.0)
115 }
116}
117
118impl fmt::Display for CtxFingerprint {
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 f.write_str(&self.as_hex())
121 }
122}
123
124impl FromStr for CtxFingerprint {
125 type Err = hex::FromHexError;
126
127 fn from_str(s: &str) -> Result<Self, Self::Err> {
128 let bytes = hex::decode(s)?;
129 let fp: [u8; 8] = bytes
130 .as_slice()
131 .try_into()
132 .map_err(|_| hex::FromHexError::InvalidHexCharacter { c: '?', index: 0 })?;
133 Ok(Self(fp))
134 }
135}
136
137impl Serialize for CtxFingerprint {
138 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
139 s.serialize_str(&self.as_hex())
140 }
141}
142
143impl<'de> Deserialize<'de> for CtxFingerprint {
144 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
145 let s = String::deserialize(d)?;
146 Self::from_str(&s).map_err(serde::de::Error::custom)
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub struct EventHash(pub [u8; 32]);
152
153impl EventHash {
154 #[must_use]
155 pub fn chain(prev: Option<EventHash>, run_id: RunId, seq: Seq, payload_json: &[u8]) -> Self {
156 let mut h = Sha256::new();
157 match prev {
158 None => h.update([0u8; 32]),
159 Some(p) => h.update(p.0),
160 }
161 h.update(run_id.as_uuid().as_bytes());
162 h.update(seq.to_le_bytes());
163 h.update(payload_json);
164 Self(h.finalize().into())
165 }
166
167 #[must_use]
168 pub const fn zero() -> Self {
169 Self([0u8; 32])
170 }
171
172 #[must_use]
173 pub fn as_hex(&self) -> String {
174 hex::encode(self.0)
175 }
176}
177
178impl fmt::Display for EventHash {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 f.write_str(&self.as_hex())
181 }
182}
183
184impl FromStr for EventHash {
185 type Err = hex::FromHexError;
186
187 fn from_str(s: &str) -> Result<Self, Self::Err> {
188 let bytes = hex::decode(s)?;
189 let arr: [u8; 32] = bytes
190 .as_slice()
191 .try_into()
192 .map_err(|_| hex::FromHexError::InvalidHexCharacter { c: '?', index: 0 })?;
193 Ok(Self(arr))
194 }
195}
196
197impl Serialize for EventHash {
198 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
199 s.serialize_str(&self.as_hex())
200 }
201}
202
203impl<'de> Deserialize<'de> for EventHash {
204 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
205 let s = String::deserialize(d)?;
206 Self::from_str(&s).map_err(serde::de::Error::custom)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn roundtrip_display_and_fromstr() {
216 let id = RunId::new();
217 let text = id.to_string();
218 let parsed: RunId = text.parse().unwrap();
219 assert_eq!(id, parsed);
220 }
221
222 #[test]
223 fn serde_is_transparent() {
224 let id = TaskId::new();
225 let json = serde_json::to_string(&id).unwrap();
226 assert_eq!(json, format!("\"{}\"", id));
227 let back: TaskId = serde_json::from_str(&json).unwrap();
228 assert_eq!(back, id);
229 }
230
231 #[test]
232 fn fingerprint_is_stable_and_order_sensitive() {
233 let a = CtxFingerprint::digest(&[b"system", b"work"]);
234 let b = CtxFingerprint::digest(&[b"system", b"work"]);
235 let c = CtxFingerprint::digest(&[b"work", b"system"]);
236 assert_eq!(a, b);
237 assert_ne!(a, c);
238 assert_eq!(a.to_string().len(), 16);
239 }
240
241 #[test]
242 fn event_hash_chain_detects_tampering() {
243 let id = RunId::new();
244 let h1 = EventHash::chain(None, id, 1, b"{}");
245 let h2 = EventHash::chain(Some(h1), id, 2, b"{}");
246 let h2b = EventHash::chain(Some(h1), id, 2, b"{\"x\":1}");
247 assert_ne!(h2, h2b);
248 assert_eq!(h2, EventHash::chain(Some(h1), id, 2, b"{}"));
249 }
250}
Deleted lib/agent-types/src/lib.rs lib/agent-types/src/lib.rs +0 −46
@@ -1,46 +0,0 @@
1#![warn(clippy::unwrap_used, clippy::expect_used)]
2
3pub mod api;
4pub mod artifact;
5pub mod budget;
6pub mod consts;
7pub mod context;
8pub mod decision;
9pub mod event;
10pub mod ids;
11pub mod llm;
12pub mod plan;
13pub mod queue;
14pub mod quota;
15pub mod sandbox;
16pub mod session;
17pub mod skill;
18pub mod state;
19pub mod tool;
20pub mod trace;
21
22use std::time::Duration;
23
24#[must_use]
25pub fn now_millis() -> i64 {
26 chrono::Utc::now().timestamp_millis()
27}
28
29#[must_use]
30pub fn duration_to_millis(d: Duration) -> u64 {
31 u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
32}
33
34#[must_use]
35pub fn checksum_of(bytes: &[u8]) -> String {
36 use sha2::Digest as _;
37 let mut h = sha2::Sha256::new();
38 sha2::Digest::update(&mut h, bytes);
39 let out: [u8; 32] = sha2::Digest::finalize(h).into();
40 format!("sha256:{}", hex::encode(out))
41}
42
43#[must_use]
44pub fn millis_to_duration(ms: u64) -> Duration {
45 Duration::from_millis(ms)
46}
Deleted lib/agent-types/src/llm.rs lib/agent-types/src/llm.rs +0 −293
@@ -1,293 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::CallId;
6use crate::tool::ToolCallSpec;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(transparent)]
10pub struct ModelId(pub SmolStr);
11
12impl ModelId {
13 #[must_use]
14 pub fn new(name: &str) -> Self {
15 Self(name.into())
16 }
17
18 #[must_use]
19 pub fn as_str(&self) -> &str {
20 &self.0
21 }
22
23 #[must_use]
24 pub fn is_empty(&self) -> bool {
25 self.0.is_empty()
26 }
27}
28
29impl std::fmt::Display for ModelId {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.write_str(&self.0)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ChatRole {
38 System,
39 User,
40 Assistant,
41 Tool,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct ChatMessage {
46 pub role: ChatRole,
47 pub content: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub call_id: Option<CallId>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub name: Option<SmolStr>,
52}
53
54impl ChatMessage {
55 #[must_use]
56 pub fn new(role: ChatRole, content: impl Into<String>) -> Self {
57 Self { role, content: content.into(), call_id: None, name: None }
58 }
59
60 #[must_use]
61 pub fn system(content: impl Into<String>) -> Self {
62 Self::new(ChatRole::System, content)
63 }
64
65 #[must_use]
66 pub const fn role_str(&self) -> &'static str {
67 match self.role {
68 ChatRole::System => "system",
69 ChatRole::User => "user",
70 ChatRole::Assistant => "assistant",
71 ChatRole::Tool => "tool",
72 }
73 }
74
75 #[must_use]
76 pub fn estimate_tokens(&self) -> u32 {
77 crate::context::estimate_tokens(&self.content)
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
82pub struct ChatRequest {
83 pub model: ModelId,
84 pub messages: Vec<ChatMessage>,
85 pub tools: Vec<crate::tool::ToolManifest>,
86 pub max_tokens: u32,
87 pub temperature: f32,
88 pub deadline_ms: u64,
89 #[serde(default)]
90 pub parallel_tool_calls: bool,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum FinishReason {
96 Stop,
97 ToolCalls,
98 Length,
99 ContentFilter,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(tag = "chunk", rename_all = "snake_case")]
104pub enum StreamChunk {
105 TextDelta {
106 delta: SmolStr,
107 },
108 ToolCallDelta {
109 index: u32,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 call_id: Option<CallId>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 name: Option<SmolStr>,
114 arguments_delta: String,
115 },
116 Usage {
117 usage: TokenUsage,
118 },
119 Done {
120 reason: FinishReason,
121 },
122}
123
124#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
125pub struct ChatResponse {
126 pub text: String,
127 pub tool_calls: Vec<ToolCallSpec>,
128 pub usage: TokenUsage,
129 pub finish_reason: Option<FinishReason>,
130}
131
132#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
133pub struct TokenUsage {
134 pub prompt: u32,
135 pub completion: u32,
136 pub cached: u32,
137}
138
139impl TokenUsage {
140 #[must_use]
141 pub fn billable_prompt(&self) -> u32 {
142 self.prompt.saturating_sub(self.cached)
143 }
144
145 #[must_use]
146 pub fn total(&self) -> u32 {
147 self.prompt.saturating_add(self.completion)
148 }
149}
150
151impl std::ops::Add for TokenUsage {
152 type Output = TokenUsage;
153
154 fn add(self, rhs: Self) -> Self::Output {
155 Self::Output {
156 prompt: self.prompt.saturating_add(rhs.prompt),
157 completion: self.completion.saturating_add(rhs.completion),
158 cached: self.cached.saturating_add(rhs.cached),
159 }
160 }
161}
162
163impl std::ops::AddAssign for TokenUsage {
164 fn add_assign(&mut self, rhs: Self) {
165 *self = *self + rhs;
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct LlmReceipt {
171 pub model: ModelId,
172 pub degraded: bool,
173 pub attempts: u8,
174 pub usage: TokenUsage,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
178pub enum LlmError {
179 #[error("llm http error: status={status}")]
180 Http { status: u16, detail: SmolStr },
181 #[error("llm rate limited")]
182 RateLimited { retry_after_ms: Option<u64> },
183 #[error("llm deadline exceeded")]
184 Timeout,
185 #[error("context length exceeded: requested={requested}")]
186 ContextOverflow { requested: u32 },
187 #[error("llm config/argument error: {}", detail)]
188 Config { detail: SmolStr },
189 #[error("llm stream decode error: {}", detail)]
190 Stream { detail: SmolStr },
191 #[error("llm call cancelled")]
192 Cancelled,
193}
194
195impl LlmError {
196 #[must_use]
197 pub fn is_retryable(&self) -> bool {
198 match self {
199 Self::RateLimited { .. } | Self::Timeout => true,
200 Self::Http { status, .. } => *status >= 500,
201 Self::Config { .. } | Self::ContextOverflow { .. } | Self::Cancelled => false,
202 Self::Stream { .. } => true,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
208pub struct RetryPolicy {
209 pub max_attempts: u8,
210 pub base_ms: u64,
211 pub jitter: f32,
212}
213
214impl Default for RetryPolicy {
215 fn default() -> Self {
216 Self { max_attempts: 4, base_ms: 250, jitter: 0.3 }
217 }
218}
219
220impl RetryPolicy {
221 #[must_use]
222 /// 第 `attempt` 次重试的退避时长(毫秒),含等量抖动。
223 ///
224 /// 抖动不是为了安全,而是为了打散并发 run 的重试节奏:没有抖动时,
225 /// 同一批 run 会在同一时刻一起重试,等于自我 DDoS。
226 pub fn backoff_ms(&self, attempt: u8) -> u64 {
227 let shift = attempt.saturating_sub(1).min(16) as u32;
228 let base = self.base_ms.saturating_mul(1u64 << shift);
229 let ratio = f64::from(self.jitter.clamp(0.0, 1.0));
230 if ratio <= 0.0 {
231 return base;
232 }
233 let spread = (base as f64 * ratio) as u64;
234 if spread == 0 {
235 return base;
236 }
237 let low = base.saturating_sub(spread / 2);
238 low.saturating_add(rand::random::<u64>() % spread)
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn token_usage_saturates_and_splits_billing() {
248 let u = TokenUsage { prompt: 1_000, completion: 200, cached: 400 };
249 assert_eq!(u.billable_prompt(), 600);
250 assert_eq!(u.total(), 1_200);
251 assert_eq!((u + u).total(), 2_400);
252 }
253
254 #[test]
255 fn retryability_matches_5_2() {
256 assert!(LlmError::Http { status: 503, detail: "".into() }.is_retryable());
257 assert!(LlmError::RateLimited { retry_after_ms: Some(10) }.is_retryable());
258 assert!(!LlmError::Http { status: 401, detail: "".into() }.is_retryable());
259 assert!(!LlmError::ContextOverflow { requested: 200_000 }.is_retryable());
260 }
261
262 #[test]
263 fn backoff_is_exponential() {
264 // 关掉抖动后退避是精确的指数序列(抖动只覆盖叠加量,不改变指数底数)
265 let p = RetryPolicy { jitter: 0.0, ..RetryPolicy::default() };
266 assert_eq!(p.backoff_ms(1), 250);
267 assert_eq!(p.backoff_ms(3), 1_000);
268 assert_eq!(p.backoff_ms(4), 2_000);
269 }
270
271 #[test]
272 fn backoff_jitter_stays_within_band() {
273 let p = RetryPolicy::default(); // jitter = 0.3
274 let base = 1_000u64; // attempt 3 的基准
275 let spread = 300u64;
276 let low = base - spread / 2;
277 for _ in 0..64 {
278 let v = p.backoff_ms(3);
279 assert!(
280 (low..low + spread).contains(&v),
281 "抖动后的退避 {v} 必须落在 [{low}, {}) 内",
282 low + spread
283 );
284 }
285 }
286
287 #[test]
288 fn stream_chunk_serde_is_tagged() {
289 let c = StreamChunk::TextDelta { delta: "hel".into() };
290 let json = serde_json::to_string(&c).unwrap();
291 assert!(json.starts_with("{\"chunk\":\"text_delta\""), "{json}");
292 }
293}
Deleted lib/agent-types/src/plan.rs lib/agent-types/src/plan.rs +0 −303
@@ -1,303 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::ArtifactRef;
6use crate::ids::{PlanId, RunId};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum ItemState {
11 Pending,
12 InProgress,
13 Done,
14 Skipped,
15 Failed,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct PlanItem {
20 pub idx: usize,
21 pub title: SmolStr,
22 pub done: ItemState,
23 #[serde(default)]
24 pub depends_on: Vec<usize>,
25 #[serde(default)]
26 pub evidence: Vec<ArtifactRef>,
27 #[serde(default)]
28 pub failures: u8,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Plan {
33 pub id: PlanId,
34 pub run_id: RunId,
35 pub goal_digest: SmolStr,
36 pub items: Vec<PlanItem>,
37 pub revision: u32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Goal {
42 pub text: SmolStr,
43 #[serde(default)]
44 pub constraints: Vec<SmolStr>,
45 #[serde(default)]
46 pub inputs: Vec<ArtifactRef>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub history: Vec<crate::llm::ChatMessage>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum ReplanReason {
54 RepeatedFailure { idx: usize },
55 EvidenceStale { idx: usize },
56 UserConstraint { text: SmolStr },
57 CompactionLostEvidence { idx: usize },
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
61pub enum PlanError {
62 #[error("plan has no items")]
63 Empty,
64 #[error("item idx {idx} is out of range (len {len})")]
65 IndexGap { idx: usize, len: usize },
66 #[error("item {idx} depends on unknown item {dep}")]
67 UnknownDep { idx: usize, dep: usize },
68 #[error("item {idx} depends on itself")]
69 SelfDep { idx: usize },
70 #[error("dependency cycle involving item {idx}")]
71 Cycle { idx: usize },
72 #[error("item {idx} is Done without evidence")]
73 DoneWithoutEvidence { idx: usize },
74}
75
76impl Plan {
77 pub fn validate(&self, require_evidence: bool) -> Result<(), PlanError> {
78 if self.items.is_empty() {
79 return Err(PlanError::Empty);
80 }
81 for (pos, item) in self.items.iter().enumerate() {
82 if item.idx != pos {
83 return Err(PlanError::IndexGap { idx: item.idx, len: self.items.len() });
84 }
85 if require_evidence && item.done == ItemState::Done && item.evidence.is_empty() {
86 return Err(PlanError::DoneWithoutEvidence { idx: item.idx });
87 }
88 for dep in &item.depends_on {
89 if *dep == item.idx {
90 return Err(PlanError::SelfDep { idx: item.idx });
91 }
92 if *dep >= self.items.len() {
93 return Err(PlanError::UnknownDep { idx: item.idx, dep: *dep });
94 }
95 }
96 }
97 for item in &self.items {
98 if item.depends_on.iter().any(|dep| *dep > item.idx) {
99 return Err(PlanError::Cycle { idx: item.idx });
100 }
101 }
102 Ok(())
103 }
104
105 #[must_use]
106 pub fn next_actionable(&self) -> Option<&PlanItem> {
107 // 用 get() 而非下标:未通过 validate 的 Plan 里依赖可能越界,
108 // 此时按"未满足"处理(返回 None),而不是索引 panic。
109 let satisfied = |item: &PlanItem| {
110 item.depends_on.iter().all(|dep| {
111 self.items
112 .get(*dep)
113 .is_some_and(|d| matches!(d.done, ItemState::Done | ItemState::Skipped))
114 })
115 };
116 self.items.iter().find(|i| i.done == ItemState::Pending && satisfied(i))
117 }
118
119 #[must_use]
120 pub fn completion_ratio(&self) -> f32 {
121 if self.items.is_empty() {
122 return 0.0;
123 }
124 let done = self
125 .items
126 .iter()
127 .filter(|i| matches!(i.done, ItemState::Done | ItemState::Skipped))
128 .count();
129 done as f32 / self.items.len() as f32
130 }
131
132 #[must_use]
133 pub fn is_settled(&self) -> bool {
134 self.items.iter().all(|i| !matches!(i.done, ItemState::Pending | ItemState::InProgress))
135 }
136
137 pub fn record_failure(&mut self, idx: usize) -> bool {
138 if let Some(item) = self.items.get_mut(idx) {
139 item.failures = item.failures.saturating_add(1);
140 item.done = ItemState::Failed;
141 return item.failures >= 2;
142 }
143 false
144 }
145
146 pub fn set_state(&mut self, idx: usize, state: ItemState) -> bool {
147 match self.items.get_mut(idx) {
148 Some(item) if item.done != state => {
149 item.done = state;
150 true
151 }
152 _ => false,
153 }
154 }
155
156 pub fn attach_evidence(&mut self, idx: usize, artifact: &crate::artifact::ArtifactRef) -> bool {
157 let Some(item) = self.items.get_mut(idx) else {
158 return false;
159 };
160 if item.evidence.iter().any(|e| e.id == artifact.id) {
161 return false;
162 }
163 item.evidence.push(artifact.clone());
164 true
165 }
166
167 #[must_use]
168 pub fn progress_index(&self) -> Option<usize> {
169 let satisfied = |item: &PlanItem| {
170 item.depends_on.iter().all(|dep| {
171 self.items
172 .get(*dep)
173 .is_some_and(|d| matches!(d.done, ItemState::Done | ItemState::Skipped))
174 })
175 };
176 self.items.iter().position(|i| i.done == ItemState::InProgress && satisfied(i)).or_else(
177 || self.items.iter().position(|i| i.done == ItemState::Pending && satisfied(i)),
178 )
179 }
180
181 #[must_use]
182 pub fn revise(&self, mut next: Plan) -> Plan {
183 next.revision = self.revision + 1;
184 next.id = self.id;
185 next.run_id = self.run_id;
186 for item in &mut next.items {
187 if let Some(prev) = self.items.get(item.idx)
188 && prev.done == ItemState::Done
189 && !prev.evidence.is_empty()
190 {
191 item.evidence.clone_from(&prev.evidence);
192 item.done = ItemState::Done;
193 }
194 }
195 next
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::artifact::ObjectRef;
203 use crate::ids::ArtifactId;
204
205 fn item(idx: usize, title: &str, deps: &[usize]) -> PlanItem {
206 PlanItem {
207 idx,
208 title: title.into(),
209 done: ItemState::Pending,
210 depends_on: deps.to_vec(),
211 evidence: Vec::new(),
212 failures: 0,
213 }
214 }
215
216 fn plan(items: Vec<PlanItem>) -> Plan {
217 Plan {
218 id: PlanId::new(),
219 run_id: RunId::new(),
220 goal_digest: "调研 X 并产出报告".into(),
221 items,
222 revision: 0,
223 }
224 }
225
226 fn artifact() -> ArtifactRef {
227 ArtifactRef {
228 id: ArtifactId::new(),
229 kind: crate::artifact::ArtifactKind::Report,
230 object: ObjectRef {
231 bucket: "agent".into(),
232 key: "t/task/run/report/a".into(),
233 etag: None,
234 },
235 size_bytes: 1,
236 checksum: None,
237 }
238 }
239
240 #[test]
241 fn rejects_gaps_and_cycles() {
242 let bad = plan(vec![item(0, "a", &[]), item(2, "b", &[0])]);
243 assert_eq!(bad.validate(false), Err(PlanError::IndexGap { idx: 2, len: 2 }));
244 let cyc = plan(vec![item(0, "a", &[1]), item(1, "b", &[])]);
245 assert_eq!(cyc.validate(false), Err(PlanError::Cycle { idx: 0 }));
246 let self_dep = plan(vec![item(0, "a", &[0])]);
247 assert_eq!(self_dep.validate(false), Err(PlanError::SelfDep { idx: 0 }));
248 assert_eq!(plan(Vec::new()).validate(false), Err(PlanError::Empty));
249 }
250
251 #[test]
252 fn done_requires_evidence_when_verifying() {
253 let mut p = plan(vec![item(0, "抓取", &[])]);
254 p.items[0].done = ItemState::Done;
255 assert_eq!(p.validate(true), Err(PlanError::DoneWithoutEvidence { idx: 0 }));
256 p.items[0].evidence.push(artifact());
257 assert_eq!(p.validate(true), Ok(()));
258 }
259
260 #[test]
261 fn next_actionable_respects_dependencies() {
262 let mut p = plan(vec![item(0, "抓取", &[]), item(1, "汇总", &[0])]);
263 assert_eq!(p.next_actionable().map(|i| i.idx), Some(0));
264 p.items[0].done = ItemState::Done;
265 p.items[0].evidence.push(artifact());
266 assert_eq!(p.next_actionable().map(|i| i.idx), Some(1));
267 assert!(!p.is_settled());
268 }
269
270 #[test]
271 fn two_failures_trigger_replan() {
272 let mut p = plan(vec![item(0, "抓取", &[])]);
273 assert!(!p.record_failure(0));
274 assert!(p.record_failure(0), "连续两次失败应触发重规划(4.1 节)");
275 }
276
277 #[test]
278 fn revise_inherits_evidence_and_bumps_revision() {
279 let mut old = plan(vec![item(0, "抓取", &[]), item(1, "汇总", &[0])]);
280 old.items[0].done = ItemState::Done;
281 old.items[0].evidence.push(artifact());
282 let new = old.revise(plan(vec![
283 item(0, "抓取", &[]),
284 item(1, "换源汇总", &[0]),
285 item(2, "出图", &[1]),
286 ]));
287 assert_eq!(new.revision, 1);
288 assert_eq!(new.items[0].done, ItemState::Done, "已完成项必须复用证据");
289 assert_eq!(new.items[0].evidence.len(), 1);
290 assert_eq!(new.items[1].done, ItemState::Pending);
291 }
292
293 #[test]
294 fn completion_ratio_counts_skipped_as_settled() {
295 let mut p = plan(vec![item(0, "a", &[]), item(1, "b", &[])]);
296 p.items[0].done = ItemState::Done;
297 p.items[0].evidence.push(artifact());
298 p.items[1].done = ItemState::Skipped;
299 assert!((p.completion_ratio() - 1.0).abs() < f32::EPSILON);
300 assert!(p.is_settled());
301 assert_eq!(p.next_actionable(), None);
302 }
303}
Deleted lib/agent-types/src/queue.rs lib/agent-types/src/queue.rs +0 −177
@@ -1,177 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::consts;
6use crate::ids::{MsgId, RunId, TenantId, WorkerId};
7
8#[derive(
9 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
10)]
11#[serde(rename_all = "snake_case")]
12pub enum Priority {
13 #[default]
14 Interactive = 0,
15 Batch = 1,
16 Maintenance = 2,
17}
18
19impl Priority {
20 #[must_use]
21 pub const fn group(self) -> &'static str {
22 match self {
23 Self::Interactive => "interactive",
24 Self::Batch => "batch",
25 Self::Maintenance => "maintenance",
26 }
27 }
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct JobEnvelope {
32 pub msg_id: MsgId,
33 pub run_id: RunId,
34 pub tenant: TenantId,
35 pub priority: Priority,
36 pub attempt: u32,
37 pub not_before_millis: i64,
38 #[serde(default)]
39 pub fingerprint: SmolStr,
40 /// 全链路追踪标识。
41 ///
42 /// Redis 队列是整条链路上**唯一**会丢失调用栈上下文的地方(提交方与消费方
43 /// 是两个任务/两个进程),所以必须显式随信封带过去。`#[serde(default)]`
44 /// 保证队列里已在途的旧消息仍能反序列化。
45 #[serde(default)]
46 pub trace_id: SmolStr,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "outcome", rename_all = "snake_case")]
51pub enum JobOutcome {
52 Done,
53 Failed { retry: bool },
54 Deferred { after_ms: u64 },
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Lease {
59 pub msg_id: MsgId,
60 pub worker: WorkerId,
61 pub lease_until_millis: i64,
62 pub ttl_ms: u64,
63}
64
65impl Lease {
66 #[must_use]
67 pub fn new(msg_id: MsgId, worker: WorkerId, ttl: std::time::Duration, now_millis: i64) -> Self {
68 Self {
69 msg_id,
70 worker,
71 lease_until_millis: now_millis.saturating_add(ttl.as_millis() as i64),
72 ttl_ms: ttl.as_millis() as u64,
73 }
74 }
75
76 #[must_use]
77 pub fn is_valid(&self, now_millis: i64) -> bool {
78 now_millis < self.lease_until_millis
79 }
80
81 #[must_use]
82 pub fn covers_step(&self, now_millis: i64, step_budget: std::time::Duration) -> bool {
83 let remaining = self.lease_until_millis.saturating_sub(now_millis).max(0) as u64;
84 remaining >= step_budget.as_millis() as u64
85 }
86
87 pub fn renew(&mut self, now_millis: i64) {
88 self.lease_until_millis =
89 now_millis.saturating_add(i64::try_from(self.ttl_ms).unwrap_or(i64::MAX));
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94pub struct LeasePolicy {
95 pub ttl_ms: u64,
96 pub heartbeat_ms: u64,
97 pub reclaim_grace_ms: u64,
98}
99
100impl Default for LeasePolicy {
101 fn default() -> Self {
102 Self {
103 ttl_ms: consts::LEASE_TTL.as_millis() as u64,
104 heartbeat_ms: consts::LEASE_HEARTBEAT.as_millis() as u64,
105 reclaim_grace_ms: consts::LEASE_HEARTBEAT.as_millis() as u64 * 2,
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub struct FairnessPolicy {
112 pub tenant_soft_cap: u32,
113 pub demote_priority: Priority,
114}
115
116impl Default for FairnessPolicy {
117 fn default() -> Self {
118 Self {
119 tenant_soft_cap: consts::TENANT_CONCURRENCY_SOFT_CAP,
120 demote_priority: Priority::Maintenance,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub struct BackpressurePolicy {
127 pub high_watermark: u32,
128 pub low_watermark: u32,
129}
130
131impl Default for BackpressurePolicy {
132 fn default() -> Self {
133 Self { high_watermark: 500, low_watermark: 200 }
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum Admission {
139 Accept,
140 Reject,
141 Demote(Priority),
142}
143
144#[must_use]
145pub fn admit(
146 priority: Priority,
147 backlog: u32,
148 tenant_inflight: u32,
149 bp: &BackpressurePolicy,
150 fair: &FairnessPolicy,
151) -> Admission {
152 if backlog >= bp.high_watermark && priority != Priority::Interactive {
153 return Admission::Reject;
154 }
155 if tenant_inflight >= fair.tenant_soft_cap {
156 return Admission::Demote(fair.demote_priority);
157 }
158 Admission::Accept
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
162pub enum QueueError {
163 #[error("unknown message {0}")]
164 UnknownMsg(MsgId),
165 #[error("lease lost for run {0}")]
166 LeaseLost(RunId),
167 /// 该消息的租约已被回收并被其他 worker 重新领取,本 worker 的确认是过期的。
168 ///
169 /// 必须拒绝:否则僵尸 worker 会 HDEL 掉新持有者正在使用的 payload,
170 /// 并重复递减租户在飞计数(导致计数漂移 + 任务丢失)。
171 #[error("stale ack for message {0}: 租约已易主,拒绝过期确认")]
172 StaleAck(MsgId),
173 #[error("poison job after {attempt} attempts")]
174 Poison { attempt: u32 },
175 #[error("queue backend error: {0}")]
176 Backend(SmolStr),
177}
Deleted lib/agent-types/src/quota.rs lib/agent-types/src/quota.rs +0 −116
@@ -1,116 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3
4use crate::ids::TenantId;
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct TenantQuota {
8 pub tenant: TenantId,
9 pub rpm: u32,
10 pub burst: u32,
11 pub max_concurrent_runs: u32,
12 pub token_budget_per_run: u32,
13 pub sandbox_mem_mib: u32,
14 pub retention_days: i64,
15}
16
17impl Default for TenantQuota {
18 fn default() -> Self {
19 Self {
20 tenant: TenantId::default(),
21 rpm: 60,
22 burst: 20,
23 max_concurrent_runs: 16,
24 token_budget_per_run: 2_000_000,
25 sandbox_mem_mib: 65_536,
26 retention_days: crate::consts::ARTIFACT_RETENTION_DAYS,
27 }
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct TokenBucket {
33 capacity_milli: i64,
34 refill_per_sec_milli: i64,
35 tokens_milli: i64,
36 last_millis: i64,
37}
38
39impl TokenBucket {
40 #[must_use]
41 pub fn new(rpm: u32, burst: u32, now_millis: i64) -> Self {
42 let capacity = i64::from(burst) * 1_000;
43 Self {
44 capacity_milli: capacity.max(1_000),
45 refill_per_sec_milli: i64::from(rpm) * 1_000 / 60,
46 tokens_milli: capacity.max(1_000),
47 last_millis: now_millis,
48 }
49 }
50
51 /// 该桶在 `now_millis` 时是否已经回满。
52 ///
53 /// 回满的桶与全新建的桶行为完全一致,因此可以从限流表里安全剔除——
54 /// 这是防止「按调用方可控的键无限增长内存」的关键手段。
55 #[must_use]
56 pub fn is_idle_at(&self, now_millis: i64) -> bool {
57 let elapsed = now_millis.saturating_sub(self.last_millis).max(0);
58 let refill = elapsed.saturating_mul(self.refill_per_sec_milli) / 1_000;
59 self.tokens_milli.saturating_add(refill) >= self.capacity_milli
60 }
61
62 #[must_use]
63 pub fn try_take(&mut self, cost: u32, now_millis: i64) -> RateVerdict {
64 // 全程使用饱和运算:系统时钟前跳或极端 rpm/burst 配置都不得触发
65 // debug 构建的 overflow panic。
66 let elapsed = now_millis.saturating_sub(self.last_millis).max(0);
67 let refill = elapsed.saturating_mul(self.refill_per_sec_milli) / 1_000;
68 self.tokens_milli = self.tokens_milli.saturating_add(refill).min(self.capacity_milli);
69 self.last_millis = now_millis;
70 let need = i64::from(cost).saturating_mul(1_000);
71 if self.tokens_milli >= need {
72 self.tokens_milli -= need;
73 RateVerdict::Allow
74 } else {
75 let missing = need.saturating_sub(self.tokens_milli);
76 let wait_ms = if self.refill_per_sec_milli <= 0 {
77 u64::MAX
78 } else {
79 u64::try_from(missing.saturating_mul(1_000) / self.refill_per_sec_milli)
80 .unwrap_or(u64::MAX)
81 };
82 RateVerdict::Deny { retry_after_ms: wait_ms }
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RateVerdict {
89 Allow,
90 Deny { retry_after_ms: u64 },
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn bucket_refills_linearly() {
99 let mut b = TokenBucket::new(60, 2, 0);
100 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
101 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
102 let denied = b.try_take(1, 0);
103 assert!(matches!(denied, RateVerdict::Deny { .. }));
104 assert_eq!(b.try_take(1, 1_000), RateVerdict::Allow, "1 秒后补回 1 个令牌");
105 }
106
107 #[test]
108 fn deny_reports_retry_after() {
109 let mut b = TokenBucket::new(6, 1, 0);
110 assert_eq!(b.try_take(1, 0), RateVerdict::Allow);
111 match b.try_take(1, 0) {
112 RateVerdict::Deny { retry_after_ms } => assert!(retry_after_ms >= 1_000),
113 RateVerdict::Allow => panic!("should be denied"),
114 }
115 }
116}
Deleted lib/agent-types/src/sandbox.rs lib/agent-types/src/sandbox.rs +0 −300
@@ -1,300 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::ids::TenantId;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub struct ImageRef {
9 pub reference: SmolStr,
10 pub toolchain_digest: SmolStr,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(tag = "mode", rename_all = "snake_case")]
15pub enum NetPolicy {
16 #[default]
17 NoNet,
18 Allowlist {
19 domains: Vec<SmolStr>,
20 },
21 Full,
22}
23
24/// 沙箱出网时**无条件**禁止访问的 IP。
25///
26/// 判据是"任何正常业务都不该访问,且访问成功即构成安全问题":
27/// - link-local(169.254.0.0/16、fe80::/10):**云元数据服务 169.254.169.254 就在这里**,
28/// 能连上就能换取节点 IAM 凭据。这是本次唯一的无条件拒绝项,也是真实的安全发现。
29/// - 未指定地址(0.0.0.0、::)、广播、组播:没有合法的出网语义。
30/// - IPv6 ULA(fc00::/7)与 IPv4 私有网段同类,按"私有"处理。
31///
32/// **刻意不含 loopback**:127.0.0.0/8 是沙箱自己的回环口,
33/// 本地开发/测试脚手架(例如本仓 golden harness 就显式白名单了 127.0.0.1 的取源服务)
34/// 与同 netns 的 sidecar 都依赖它。把它一律拒掉会误伤正常用法,
35/// 而它并不是凭据泄漏路径(meta 服务在 link-local,不在 loopback)。
36#[must_use]
37pub fn is_unreachable_egress_ip(ip: std::net::IpAddr) -> bool {
38 match ip {
39 std::net::IpAddr::V4(v4) => {
40 v4.is_link_local() || v4.is_unspecified() || v4.is_multicast() || v4.is_broadcast()
41 }
42 std::net::IpAddr::V6(v6) => {
43 v6.is_unspecified()
44 || v6.is_multicast()
45 || (v6.segments()[0] & 0xffc0) == 0xfe80
46 || (v6.segments()[0] & 0xfe00) == 0xfc00
47 }
48 }
49}
50
51/// RFC1918 / CGNAT / loopback 等"内网"地址。
52///
53/// 默认禁止(多租户沙箱不应能扫内网,也不该借回环打到同 netns 的 sidecar 管理口),
54/// 但可用 `AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS=1` 显式放开——部署形态各异,
55/// 有的确实需要访问集群内私有服务或本机回环。
56#[must_use]
57pub fn is_private_egress_ip(ip: std::net::IpAddr) -> bool {
58 match ip {
59 std::net::IpAddr::V4(v4) => {
60 let [a, b, ..] = v4.octets();
61 v4.is_loopback()
62 || a == 10
63 || (a == 172 && (16..=31).contains(&b))
64 || (a == 192 && b == 168)
65 || (a == 100 && (64..=127).contains(&b))
66 }
67 std::net::IpAddr::V6(v6) => v6.is_loopback(),
68 }
69}
70
71/// 私有网段是否被显式放开。
72///
73/// fail-closed:只有取值恰为 `1` 才放开,其余任何取值(含拼错、`true`、空串)一律视为不放开。
74#[must_use]
75pub fn private_egress_allowed() -> bool {
76 matches!(std::env::var("AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS").as_deref(), Ok("1"))
77}
78
79/// 综合判定:沙箱是否允许访问该 IP。
80#[must_use]
81pub fn egress_ip_allowed(ip: std::net::IpAddr) -> bool {
82 !is_unreachable_egress_ip(ip) && (!is_private_egress_ip(ip) || private_egress_allowed())
83}
84
85impl NetPolicy {
86 #[must_use]
87 pub fn allows_host(&self, host: &str) -> bool {
88 let host = host.trim_end_matches('.').to_ascii_lowercase();
89 if host.is_empty() {
90 return false;
91 }
92 let parsed = host.trim_matches(['[', ']']).parse::<std::net::IpAddr>().ok();
93 // 无条件拒绝集(云元数据等):**任何策略、任何显式白名单都不能放行**。
94 // 放行 169.254.169.254 就等于把节点 IAM 凭据交给沙箱,不存在"业务确实需要"的场景。
95 if let Some(ip) = parsed
96 && is_unreachable_egress_ip(ip)
97 {
98 return false;
99 }
100 match self {
101 Self::NoNet => false,
102 Self::Full => match parsed {
103 // Full 也要挡住内网与回环:否则一个 Full 策略就足以把集群内网扫一遍。
104 Some(ip) => !is_private_egress_ip(ip) || private_egress_allowed(),
105 // 域名形式无法在此判定 IP(要等 DNS 解析),交给 k8s NetworkPolicy
106 // 的 CIDR 过滤兜住。
107 None => true,
108 },
109 Self::Allowlist { domains } => {
110 let listed = domains.iter().any(|d| {
111 let d = d.trim().trim_end_matches('.').to_ascii_lowercase();
112 match d.strip_prefix('.') {
113 Some(suffix) => host == suffix || host.ends_with(&format!(".{suffix}")),
114 None => host == d,
115 }
116 });
117 // 显式列出即视为操作者的有意决定,对私有/回环网段同样生效——
118 // 否则本地脚手架(golden harness 白名单了 127.0.0.1 的取源服务)
119 // 与"确实要访问内网服务"的部署都没法表达意图。
120 listed
121 }
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127#[allow(clippy::derived_hash_with_manual_eq)]
128pub struct SandboxSpec {
129 pub image: ImageRef,
130 pub cpu: f32,
131 pub mem_mib: u32,
132 pub disk_mib: u32,
133 pub net: NetPolicy,
134 pub ttl_ms: u64,
135 pub tenant: TenantId,
136 #[serde(default)]
137 pub runtime_class: SmolStr,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub workspace_key: Option<SmolStr>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct FetchResult {
144 pub status: u16,
145 pub body: String,
146 pub bytes: u64,
147 pub truncated: bool,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub content_type: Option<SmolStr>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct CmdSpec {
154 pub argv: Vec<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub cwd: Option<SmolStr>,
157 #[serde(default)]
158 pub env: std::collections::BTreeMap<SmolStr, SmolStr>,
159 pub timeout_ms: u64,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub stdin: Option<String>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct ExecResult {
166 pub exit_code: i32,
167 pub stdout: String,
168 pub stderr: String,
169 pub truncated: bool,
170 pub duration_ms: u64,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(tag = "stream", rename_all = "snake_case")]
175pub enum OutputChunk {
176 Stdout { text: String },
177 Stderr { text: String },
178 Exit { code: i32 },
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum ReleaseMode {
184 ReturnToPool,
185 Destroy,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189pub struct PoolConfig {
190 pub min_idle: u32,
191 pub max_idle: u32,
192 pub reconcile_interval_ms: u64,
193 pub acquire_timeout_ms: u64,
194}
195
196impl Default for PoolConfig {
197 fn default() -> Self {
198 Self {
199 min_idle: crate::consts::SANDBOX_POOL_MIN_IDLE,
200 max_idle: crate::consts::SANDBOX_POOL_MAX_IDLE,
201 reconcile_interval_ms: 5_000,
202 acquire_timeout_ms: 15_000,
203 }
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
208pub enum SbError {
209 #[error("sandbox pool exhausted after {waited_ms}ms")]
210 PoolExhausted { waited_ms: u64 },
211 #[error("sandbox create failed: {detail}")]
212 Create { detail: SmolStr },
213 #[error("sandbox instance lost: {detail}")]
214 Lost { detail: SmolStr },
215 #[error("file gateway error: {detail}")]
216 Gateway { detail: SmolStr },
217 #[error("sandbox ttl exceeded")]
218 TtlExceeded,
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 /// 无条件拒绝集:云元数据等。**任何策略、任何显式白名单都不能放行**。
226 /// 放行 169.254.169.254 等于把节点 IAM 凭据交给沙箱。
227 #[test]
228 fn never_valid_addresses_are_always_denied() {
229 for host in
230 ["169.254.169.254", "0.0.0.0", "255.255.255.255", "224.0.0.1", "fe80::1", "fc00::1"]
231 {
232 assert!(!NetPolicy::Full.allows_host(host), "Full 不得放行 {host}");
233 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new(host)] };
234 assert!(!listed.allows_host(host), "白名单里显式列出 {host} 也不得放行");
235 assert!(!NetPolicy::NoNet.allows_host(host));
236 }
237 }
238
239 #[test]
240 fn public_addresses_are_allowed() {
241 assert!(NetPolicy::Full.allows_host("8.8.8.8"));
242 assert!(NetPolicy::Full.allows_host("example.com"));
243 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new("example.com")] };
244 assert!(listed.allows_host("example.com"));
245 }
246
247 /// `Full` 下私有网段与回环默认拒绝,可由 `AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS=1` 放开。
248 #[test]
249 fn full_denies_private_and_loopback_by_default() {
250 let opt_in = private_egress_allowed();
251 for host in ["10.0.0.1", "172.16.0.1", "192.168.1.1", "100.64.0.1", "127.0.0.1", "::1"] {
252 assert_eq!(
253 NetPolicy::Full.allows_host(host),
254 opt_in,
255 "{host} 的判定应跟随 AGENT_SANDBOX_ALLOW_PRIVATE_EGRESS"
256 );
257 }
258 }
259
260 /// 显式白名单是操作者的有意决定,对私有/回环同样生效。
261 /// 本仓的 golden harness 正是靠这一条访问 127.0.0.1 上的取源服务。
262 #[test]
263 fn explicit_allowlist_wins_over_private_default_deny() {
264 let listed = NetPolicy::Allowlist { domains: vec![SmolStr::new("127.0.0.1")] };
265 assert!(listed.allows_host("127.0.0.1"), "显式列出的回环地址应放行");
266 assert!(!listed.allows_host("127.0.0.2"), "未列出的回环地址仍拒绝");
267 assert!(!listed.allows_host("10.0.0.1"), "未列出的私有地址仍拒绝");
268 }
269
270 #[test]
271 fn no_net_denies_everything() {
272 assert!(!NetPolicy::NoNet.allows_host("8.8.8.8"));
273 assert!(!NetPolicy::NoNet.allows_host("example.com"));
274 }
275
276 /// 白名单的后缀匹配语义不能被这次改动破坏。
277 #[test]
278 fn allowlist_suffix_matching_still_works() {
279 let policy = NetPolicy::Allowlist { domains: vec![SmolStr::new(".example.com")] };
280 assert!(policy.allows_host("api.example.com"));
281 assert!(policy.allows_host("example.com"));
282 assert!(!policy.allows_host("evil.com"));
283 }
284
285 #[test]
286 fn egress_verdicts_cover_range_edges() {
287 assert!(is_unreachable_egress_ip("169.254.1.1".parse().unwrap()));
288 assert!(is_unreachable_egress_ip("224.0.0.1".parse().unwrap()));
289 assert!(is_unreachable_egress_ip("fc00::1".parse().unwrap()));
290 assert!(
291 !is_unreachable_egress_ip("127.0.0.1".parse().unwrap()),
292 "loopback 不属于无条件拒绝集(它归在私有网段里,可被显式放开)"
293 );
294 assert!(is_private_egress_ip("127.0.0.1".parse().unwrap()));
295 assert!(is_private_egress_ip("::1".parse().unwrap()));
296 assert!(is_private_egress_ip("172.31.255.255".parse().unwrap()));
297 assert!(!is_private_egress_ip("172.32.0.1".parse().unwrap()), "172.32 不在 RFC1918 内");
298 assert!(!is_private_egress_ip("11.0.0.1".parse().unwrap()));
299 }
300}
Deleted lib/agent-types/src/session.rs lib/agent-types/src/session.rs +0 −250
@@ -1,250 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::budget::RunLimits;
6use crate::ids::{GrantId, MessageId, RunId, SessionId, TenantId};
7use crate::state::RunState;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum MessageRole {
12 User,
13 Assistant,
14}
15
16impl MessageRole {
17 #[must_use]
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::User => "user",
21 Self::Assistant => "assistant",
22 }
23 }
24}
25
26impl std::fmt::Display for MessageRole {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.write_str(self.as_str())
29 }
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct SessionRow {
34 pub session_id: SessionId,
35 pub title: SmolStr,
36 pub metadata: serde_json::Value,
37 pub created_at_millis: i64,
38 pub updated_at_millis: i64,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub deleted_at_millis: Option<i64>,
41}
42
43impl SessionRow {
44 #[must_use]
45 pub fn new(
46 session_id: SessionId,
47 title: SmolStr,
48 metadata: serde_json::Value,
49 now: i64,
50 ) -> Self {
51 Self {
52 session_id,
53 title,
54 metadata,
55 created_at_millis: now,
56 updated_at_millis: now,
57 deleted_at_millis: None,
58 }
59 }
60
61 #[must_use]
62 pub const fn is_deleted(&self) -> bool {
63 self.deleted_at_millis.is_some()
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct MessageRow {
69 pub message_id: MessageId,
70 pub session_id: SessionId,
71 pub seq: u64,
72 pub role: MessageRole,
73 pub content: SmolStr,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub run_id: Option<RunId>,
76 pub metadata: serde_json::Value,
77 pub created_at_millis: i64,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct SessionDto {
82 pub session_id: SessionId,
83 pub title: SmolStr,
84 pub metadata: serde_json::Value,
85 pub created_at_millis: i64,
86 pub updated_at_millis: i64,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub deleted_at_millis: Option<i64>,
89}
90
91impl From<&SessionRow> for SessionDto {
92 fn from(row: &SessionRow) -> Self {
93 Self {
94 session_id: row.session_id,
95 title: row.title.clone(),
96 metadata: row.metadata.clone(),
97 created_at_millis: row.created_at_millis,
98 updated_at_millis: row.updated_at_millis,
99 deleted_at_millis: row.deleted_at_millis,
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct MessageDto {
106 pub message_id: MessageId,
107 pub session_id: SessionId,
108 pub seq: u64,
109 pub role: MessageRole,
110 pub content: SmolStr,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub run_id: Option<RunId>,
113 pub created_at_millis: i64,
114}
115
116impl From<&MessageRow> for MessageDto {
117 fn from(row: &MessageRow) -> Self {
118 Self {
119 message_id: row.message_id,
120 session_id: row.session_id,
121 seq: row.seq,
122 role: row.role,
123 content: row.content.clone(),
124 run_id: row.run_id,
125 created_at_millis: row.created_at_millis,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CreateSessionRequest {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub session_id: Option<SessionId>,
134 #[serde(default)]
135 pub title: Option<SmolStr>,
136 #[serde(default)]
137 pub metadata: Option<serde_json::Value>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum SubjectKindWire {
143 User,
144 Service,
145 Agent,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149pub struct CreateSessionTokenRequest {
150 pub tenant: crate::ids::TenantId,
151 pub subject: SmolStr,
152 #[serde(default)]
153 pub subject_kind: Option<SubjectKindWire>,
154 #[serde(default)]
155 pub ttl_secs: Option<u64>,
156 /// 显式权限子集,必须落在用途上限之内;超界即 400。
157 /// 缺省保持既有行为(用途上限 ∩ 主体类型基线),即不改变现存调用方语义。
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub scopes: Option<Vec<SmolStr>>,
160}
161
162/// 会话绑定登记行:`(tenant, session)` 组合构成的授权。
163///
164/// 会话本身不带租户归属——因为「换会话 / 换租户即作废」隐含会话可以被重新绑定到
165/// 另一个租户,所以 `(tenant, session)` 是一份**可变、可吊销的授权**,不是会话的固有属性。
166/// 「同一时刻仅一行活跃」这一约束由 `0004_session_grants.sql` 的两个部分唯一索引
167/// 在数据库层强制,不依赖任何调用方纪律。
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct SessionGrantRow {
170 pub grant_id: GrantId,
171 pub tenant: TenantId,
172 pub session_id: SessionId,
173 pub subject: SmolStr,
174 pub subject_kind: SubjectKindWire,
175 /// 权威权限集合(`resource:action` 字面量)。校验时与令牌的用途上限取交集,
176 /// 因此权限收窄对已签发但未过期的令牌立即生效。
177 pub scopes: Vec<SmolStr>,
178 pub issued_at_millis: i64,
179 pub expires_at_millis: i64,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub revoked_at_millis: Option<i64>,
182}
183
184impl SessionGrantRow {
185 #[must_use]
186 pub const fn is_live(&self, now_millis: i64) -> bool {
187 self.revoked_at_millis.is_none() && now_millis < self.expires_at_millis
188 }
189}
190
191/// 换绑结果。冲突时携带导致冲突的活跃绑定,便于调用方自愈或返回 409。
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub enum GrantBindOutcome {
194 Bound,
195 Conflict { active: Box<SessionGrantRow> },
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
199pub struct SendMessageRequest {
200 pub text: SmolStr,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub idempotency_key: Option<SmolStr>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub limits: Option<RunLimits>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct MessageAccepted {
209 pub message_id: MessageId,
210 pub session_id: SessionId,
211 pub seq: u64,
212 pub run_id: RunId,
213 pub state: RunState,
214 pub events_url: SmolStr,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218pub struct SessionTokenDto {
219 pub token: SmolStr,
220 pub session_id: SessionId,
221 pub tenant: crate::ids::TenantId,
222 pub expires_in_secs: u64,
223 /// 绑定登记 id:可信后端可据此吊销或清点本次授权。
224 pub grant_id: GrantId,
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn message_role_serializes_lowercase() {
233 assert_eq!(serde_json::to_string(&MessageRole::Assistant).unwrap(), "\"assistant\"");
234 assert_eq!(serde_json::to_string(&MessageRole::User).unwrap(), "\"user\"");
235 }
236
237 #[test]
238 fn send_message_request_defaults_optionals() {
239 let req: SendMessageRequest = serde_json::from_str(r#"{"text":"你好"}"#).unwrap();
240 assert_eq!(req.text, "你好");
241 assert!(req.idempotency_key.is_none());
242 assert!(req.limits.is_none());
243 }
244
245 #[test]
246 fn session_row_soft_delete_flag() {
247 let row = SessionRow::new(SessionId::new(), "t".into(), serde_json::json!({}), 1);
248 assert!(!row.is_deleted());
249 }
250}
Deleted lib/agent-types/src/skill.rs lib/agent-types/src/skill.rs +0 −42
@@ -1,42 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::consts::SKILL_BODY_MAX_BYTES;
6
7/// 一个已发现的技能:`SKILL.md` 的 frontmatter 摘要 + 正文哈希。
8///
9/// `hash` 是 SKILL.md 原始字节的 `sha256:<hex>`(见 `crate::checksum_of`)。
10/// 事件流以该结构钉住清单,重放时不再读取本机文件,从而让 `ctx_fp` 可复现。
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SkillEntry {
13 pub name: SmolStr,
14 pub description: SmolStr,
15 pub hash: SmolStr,
16}
17
18impl SkillEntry {
19 #[must_use]
20 pub fn new(
21 name: impl Into<SmolStr>,
22 description: impl Into<SmolStr>,
23 hash: impl Into<SmolStr>,
24 ) -> Self {
25 Self { name: name.into(), description: description.into(), hash: hash.into() }
26 }
27}
28
29/// 技能名的合法字符集:ASCII 字母数字与 `.` `_` `-`;长度 1..=64;不得是 `.` / `..`。
30#[must_use]
31pub fn valid_skill_name(name: &str) -> bool {
32 if name.is_empty() || name.len() > 64 || name == "." || name == ".." {
33 return false;
34 }
35 name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
36}
37
38/// 技能正文是否超出行内上限(按字节计)。
39#[must_use]
40pub fn body_within_limit(body: &str) -> bool {
41 body.len() <= SKILL_BODY_MAX_BYTES
42}
Deleted lib/agent-types/src/state.rs lib/agent-types/src/state.rs +0 −165
@@ -1,165 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
6#[serde(rename_all = "snake_case")]
7pub enum RunState {
8 Pending,
9 Planning,
10 Executing,
11 AwaitingApproval,
12 Verifying,
13 Deferred,
14 Completed,
15 Failed,
16 Cancelled,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Transition {
22 ClaimedFresh,
23 ClaimedResume,
24 PlanAdopted,
25 StepFinished,
26 FinishRequested,
27 ApprovalRequired,
28 ApprovalGranted,
29 ApprovalDenied,
30 VerificationPassed,
31 VerificationFailed,
32 Defer,
33 Resume,
34 Cancel,
35 BudgetExhausted,
36 Fatal,
37 LeaseReleased,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum FailReason {
43 StepBudget,
44 TokenBudget,
45 WallclockBudget,
46 Fatal { detail: SmolStr },
47 SandboxExhausted,
48 PoisonPill,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum TerminalState {
54 Completed,
55 Failed { reason: FailReason },
56 Cancelled,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
60pub enum TransitionError {
61 #[error("illegal transition: {from:?} --{trigger:?}--> (no target)")]
62 Illegal { from: RunState, trigger: Transition },
63 #[error("state {0:?} is terminal")]
64 Terminal(RunState),
65}
66
67impl RunState {
68 pub const ALL: [RunState; 9] = [
69 RunState::Pending,
70 RunState::Planning,
71 RunState::Executing,
72 RunState::AwaitingApproval,
73 RunState::Verifying,
74 RunState::Deferred,
75 RunState::Completed,
76 RunState::Failed,
77 RunState::Cancelled,
78 ];
79
80 pub const TRIGGERS: [Transition; 16] = [
81 Transition::ClaimedFresh,
82 Transition::ClaimedResume,
83 Transition::PlanAdopted,
84 Transition::StepFinished,
85 Transition::FinishRequested,
86 Transition::ApprovalRequired,
87 Transition::ApprovalGranted,
88 Transition::ApprovalDenied,
89 Transition::VerificationPassed,
90 Transition::VerificationFailed,
91 Transition::Defer,
92 Transition::Resume,
93 Transition::Cancel,
94 Transition::BudgetExhausted,
95 Transition::Fatal,
96 Transition::LeaseReleased,
97 ];
98
99 #[must_use]
100 pub fn is_terminal(self) -> bool {
101 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
102 }
103
104 #[must_use]
105 pub fn holds_lease(self) -> bool {
106 matches!(self, Self::Planning | Self::Executing | Self::Verifying)
107 }
108
109 #[must_use]
110 pub fn may_hold_sandbox(self) -> bool {
111 matches!(self, Self::Executing | Self::Verifying)
112 }
113
114 #[must_use]
115 pub fn is_cancellable(self) -> bool {
116 !self.is_terminal()
117 }
118}
119
120pub fn next_state(from: RunState, trigger: Transition) -> Result<RunState, TransitionError> {
121 use RunState::*;
122 if from.is_terminal() {
123 return Err(TransitionError::Terminal(from));
124 }
125 let to = match (from, trigger) {
126 (_, Transition::Cancel) => Cancelled,
127 (_, Transition::LeaseReleased) => Pending,
128
129 (Pending, Transition::ClaimedFresh) => Planning,
130 (Pending, Transition::ClaimedResume) => Executing,
131 (Pending, Transition::Resume) => Pending,
132 (Pending, Transition::BudgetExhausted) => Failed,
133
134 (Planning, Transition::PlanAdopted) => Executing,
135 (Planning, Transition::Defer) => Deferred,
136 (Planning, Transition::BudgetExhausted) => Failed,
137 (Planning, Transition::Fatal) => Failed,
138
139 (Executing, Transition::StepFinished) => Executing,
140 (Executing, Transition::FinishRequested) => Verifying,
141 (Executing, Transition::ApprovalRequired) => AwaitingApproval,
142 (Executing, Transition::VerificationFailed) => Planning,
143 (Executing, Transition::Defer) => Deferred,
144 (Executing, Transition::BudgetExhausted) => Failed,
145 (Executing, Transition::Fatal) => Failed,
146
147 (AwaitingApproval, Transition::ApprovalGranted) => Executing,
148 (AwaitingApproval, Transition::ApprovalDenied) => Executing,
149 (AwaitingApproval, Transition::Defer) => Deferred,
150
151 (Verifying, Transition::VerificationPassed) => Completed,
152 (Verifying, Transition::VerificationFailed) => Planning,
153 (Verifying, Transition::ApprovalRequired) => AwaitingApproval,
154 (Verifying, Transition::StepFinished) => Verifying,
155 (Verifying, Transition::BudgetExhausted) => Failed,
156 (Verifying, Transition::Fatal) => Failed,
157
158 (Deferred, Transition::Resume) => Pending,
159 (Deferred, Transition::BudgetExhausted) => Failed,
160 (Deferred, Transition::Fatal) => Failed,
161
162 (from, trigger) => return Err(TransitionError::Illegal { from, trigger }),
163 };
164 Ok(to)
165}
Deleted lib/agent-types/src/tool.rs lib/agent-types/src/tool.rs +0 −417
@@ -1,417 +0,0 @@
1use serde::Deserialize;
2use serde::Serialize;
3use smol_str::SmolStr;
4
5use crate::artifact::ArtifactRef;
6use crate::ids::{CallId, RunId, StepNo, TenantId};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum RiskLevel {
11 ReadOnly,
12 Mutating,
13 Dangerous,
14}
15
16impl RiskLevel {
17 #[must_use]
18 pub const fn as_str(self) -> &'static str {
19 match self {
20 Self::ReadOnly => "read_only",
21 Self::Mutating => "mutating",
22 Self::Dangerous => "dangerous",
23 }
24 }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum StepBudgetHint {
30 Quick,
31 Standard,
32 Long,
33}
34
35impl StepBudgetHint {
36 #[must_use]
37 pub const fn exec_timeout_millis(self) -> u64 {
38 match self {
39 Self::Quick => 5_000,
40 Self::Standard => 60_000,
41 Self::Long => 600_000,
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Hash, Serialize, Deserialize, Default)]
47#[serde(transparent)]
48pub struct JsonSchema(pub serde_json::Value);
49
50impl JsonSchema {
51 #[must_use]
52 pub fn any() -> Self {
53 Self(serde_json::json!({}))
54 }
55
56 #[must_use]
57 pub fn from_value(v: serde_json::Value) -> Self {
58 Self(v)
59 }
60
61 #[must_use]
62 pub fn raw(&self) -> &serde_json::Value {
63 &self.0
64 }
65
66 pub fn validate(&self, args: &serde_json::Value) -> Result<(), Vec<SchemaViolation>> {
67 let mut errs = Vec::new();
68 check(&self.0, args, "$", &mut errs);
69 if errs.is_empty() { Ok(()) } else { Err(errs) }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub struct SchemaViolation {
75 pub path: SmolStr,
76 pub reason: SmolStr,
77}
78
79fn type_name(v: &serde_json::Value) -> &'static str {
80 match v {
81 serde_json::Value::Null => "null",
82 serde_json::Value::Bool(_) => "boolean",
83 serde_json::Value::Number(n) => {
84 if n.is_i64() || n.is_u64() {
85 "integer"
86 } else {
87 "number"
88 }
89 }
90 serde_json::Value::String(_) => "string",
91 serde_json::Value::Array(_) => "array",
92 serde_json::Value::Object(_) => "object",
93 }
94}
95
96fn type_matches(expected: &str, value: &serde_json::Value) -> bool {
97 match expected {
98 "object" => value.is_object(),
99 "array" => value.is_array(),
100 "string" => value.is_string(),
101 "boolean" => value.is_boolean(),
102 "number" => value.is_number(),
103 "integer" => {
104 value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|f| f.fract() == 0.0)
105 }
106 "null" => value.is_null(),
107 _ => true,
108 }
109}
110
111fn check(
112 schema: &serde_json::Value,
113 value: &serde_json::Value,
114 path: &str,
115 errs: &mut Vec<SchemaViolation>,
116) {
117 let Some(obj) = schema.as_object() else {
118 return;
119 };
120 if let Some(allowed) = obj.get("enum").and_then(|e| e.as_array()) {
121 if !allowed.iter().any(|a| a == value) {
122 errs.push(SchemaViolation {
123 path: path.into(),
124 reason: format!("值必须是 {:?} 之一", allowed).into(),
125 });
126 }
127 }
128 if let Some(tys) = obj.get("type") {
129 let expected: Vec<&str> = match tys {
130 serde_json::Value::String(s) => vec![s.as_str()],
131 serde_json::Value::Array(a) => a.iter().filter_map(serde_json::Value::as_str).collect(),
132 _ => Vec::new(),
133 };
134 if !expected.is_empty() && !expected.iter().any(|t| type_matches(t, value)) {
135 errs.push(SchemaViolation {
136 path: path.into(),
137 reason: format!("期望类型 {},实际 {}", expected.join("|"), type_name(value))
138 .into(),
139 });
140 return;
141 }
142 }
143 if let Some(props) = obj.get("properties").and_then(|p| p.as_object()) {
144 if let Some(map) = value.as_object() {
145 for (key, sub) in props {
146 if let Some(child) = map.get(key) {
147 check(sub, child, &format!("{path}.{key}"), errs);
148 }
149 }
150 }
151 }
152 if let Some(required) = obj.get("required").and_then(|r| r.as_array()) {
153 if let Some(map) = value.as_object() {
154 for key in required.iter().filter_map(serde_json::Value::as_str) {
155 if !map.contains_key(key) {
156 errs.push(SchemaViolation {
157 path: format!("{path}.{key}").into(),
158 reason: "缺少必填参数".into(),
159 });
160 }
161 }
162 }
163 }
164 if let Some(items) = obj.get("items") {
165 if let Some(arr) = value.as_array() {
166 for (i, el) in arr.iter().enumerate() {
167 check(items, el, &format!("{path}[{i}]"), errs);
168 }
169 }
170 }
171 // 以下四项此前**完全没有实现**,于是工具 schema 里写的 "minimum": 1 / "maxLength": 4096
172 // 全是摆设:模型传 timeout_ms=0 或一个超长字符串都能静默通过校验。
173 if let Some(n) = value.as_f64() {
174 let bound = |key: &str| obj.get(key).and_then(serde_json::Value::as_f64);
175 if let Some(min) = bound("minimum")
176 && n < min
177 {
178 errs.push(SchemaViolation {
179 path: path.into(),
180 reason: format!("{n} 小于下界 {min}").into(),
181 });
182 }
183 if let Some(max) = bound("maximum")
184 && n > max
185 {
186 errs.push(SchemaViolation {
187 path: path.into(),
188 reason: format!("{n} 大于上界 {max}").into(),
189 });
190 }
191 if let Some(step) = bound("multipleOf")
192 && step > 0.0
193 && (n / step).fract().abs() > f64::EPSILON
194 {
195 errs.push(SchemaViolation {
196 path: path.into(),
197 reason: format!("{n} 不是 {step} 的整数倍").into(),
198 });
199 }
200 }
201 if let Some(s) = value.as_str() {
202 let chars = s.chars().count() as f64;
203 if let Some(max) = obj.get("maxLength").and_then(serde_json::Value::as_f64)
204 && chars > max
205 {
206 errs.push(SchemaViolation {
207 path: path.into(),
208 reason: format!("字符串长度 {chars} 超过上限 {max}").into(),
209 });
210 }
211 if let Some(min) = obj.get("minLength").and_then(serde_json::Value::as_f64)
212 && chars < min
213 {
214 errs.push(SchemaViolation {
215 path: path.into(),
216 reason: format!("字符串长度 {chars} 小于下限 {min}").into(),
217 });
218 }
219 }
220 if let Some(arr) = value.as_array() {
221 let len = arr.len() as f64;
222 if let Some(max) = obj.get("maxItems").and_then(serde_json::Value::as_f64)
223 && len > max
224 {
225 errs.push(SchemaViolation {
226 path: path.into(),
227 reason: format!("数组长度 {len} 超过上限 {max}").into(),
228 });
229 }
230 if let Some(min) = obj.get("minItems").and_then(serde_json::Value::as_f64)
231 && len < min
232 {
233 errs.push(SchemaViolation {
234 path: path.into(),
235 reason: format!("数组长度 {len} 小于下限 {min}").into(),
236 });
237 }
238 }
239 // 未知字段:`additionalProperties: false` 时拒绝,避免模型把参数名写错却静默通过。
240 if obj.get("additionalProperties") == Some(&serde_json::Value::Bool(false))
241 && let Some(map) = value.as_object()
242 {
243 let props = obj.get("properties").and_then(|p| p.as_object());
244 for key in map.keys() {
245 let known = props.is_some_and(|p| p.contains_key(key));
246 if !known {
247 errs.push(SchemaViolation {
248 path: format!("{path}.{key}").into(),
249 reason: "不接受的参数(additionalProperties: false)".into(),
250 });
251 }
252 }
253 }
254}
255
256/// 工具副作用的可逆性声明(对标「可逆性上界」分类:Idempotent / Reversible /
257/// Compensable / Irreversible 的简化三档)。
258///
259/// 用途:审批理由与工具清单里向人/模型透出「这次动作能不能撤回」,
260/// 为后续的自动重试与回滚策略(Earliest-Conflict Rollback 等)留下元数据。
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
262#[serde(rename_all = "snake_case")]
263pub enum Reversibility {
264 /// 同参数重复执行收敛到同一结果(幂等)。
265 #[default]
266 Idempotent,
267 /// 可以用反向操作撤回(例如删除前有备份的写)。
268 Reversible,
269 /// 无法自动撤回;只能人工补偿或接受后果。
270 Irreversible,
271}
272
273impl Reversibility {
274 #[must_use]
275 pub fn as_str(self) -> &'static str {
276 match self {
277 Self::Idempotent => "idempotent",
278 Self::Reversible => "reversible",
279 Self::Irreversible => "irreversible",
280 }
281 }
282}
283
284#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ToolManifest {
286 pub name: SmolStr,
287 pub description: SmolStr,
288 pub schema: JsonSchema,
289 pub risk: RiskLevel,
290 /// 副作用可逆性
291 #[serde(default)]
292 pub reversibility: Reversibility,
293}
294
295#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
296pub struct ToolCallSpec {
297 pub call_id: CallId,
298 pub name: SmolStr,
299 pub args: serde_json::Value,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
303pub struct ToolCall {
304 pub spec: ToolCallSpec,
305 pub run_id: RunId,
306 pub step: StepNo,
307 pub tenant: TenantId,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
311pub struct ToolOutput {
312 pub observation: SmolStr,
313 #[serde(default)]
314 pub artifacts: Vec<ArtifactRef>,
315 #[serde(default)]
316 pub truncated: bool,
317 #[serde(default)]
318 pub exit_code: i32,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
322#[serde(rename_all = "snake_case")]
323pub enum ToolErrorCode {
324 NotFound,
325 SchemaViolation,
326 RejectedByPolicy,
327 AwaitingApproval,
328 Sandbox,
329 Timeout,
330 Cancelled,
331 Internal,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(tag = "status", rename_all = "snake_case")]
336pub enum ToolOutcome {
337 Ok { exit_code: i32, output: ToolOutput, duration_ms: u64 },
338 Err { code: ToolErrorCode, message: SmolStr, duration_ms: u64 },
339 Timeout { limit_ms: u64 },
340 Interrupted,
341}
342
343impl ToolOutcome {
344 #[must_use]
345 pub fn is_success(&self) -> bool {
346 matches!(self, Self::Ok { .. })
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
351pub struct PolicySet {
352 pub approval_at: RiskLevel,
353 pub allowlist: Option<Vec<SmolStr>>,
354 pub max_risk: RiskLevel,
355}
356
357impl Default for PolicySet {
358 fn default() -> Self {
359 // 审批门槛默认压到 Mutating:内置工具的最高风险级就是 Mutating,
360 // 若默认仍在 Dangerous,审批链路在默认配置下永远休眠。
361 // 无人值守场景(golden 回归、压测脚本)必须显式豁免。
362 Self { approval_at: RiskLevel::Mutating, allowlist: None, max_risk: RiskLevel::Dangerous }
363 }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(rename_all = "snake_case")]
368pub enum Authorization {
369 Execute,
370 RequireApproval,
371 Reject,
372}
373
374/// 参数字段名里出现这些子串时,值不往任何外部送:事件流、审批面、决策模型。
375///
376/// 三处共用同一个函数,口径不会分叉(否则会出现「日志里看得见、审批里看不见」
377/// 这类无法解释的差异)。
378pub fn redact_secret_args(args: &serde_json::Value) -> serde_json::Value {
379 const SECRET_HINTS: &[&str] = &["key", "token", "secret", "password", "authorization"];
380 fn scrub(value: &mut serde_json::Value) {
381 match value {
382 serde_json::Value::Object(map) => {
383 for (k, v) in map.iter_mut() {
384 if SECRET_HINTS.iter().any(|hint| k.to_lowercase().contains(hint))
385 && !v.is_object()
386 && !v.is_array()
387 {
388 *v = serde_json::Value::String("***MASKED***".to_string());
389 } else {
390 scrub(v);
391 }
392 }
393 }
394 serde_json::Value::Array(items) => items.iter_mut().for_each(scrub),
395 _ => {}
396 }
397 }
398 let mut out = args.clone();
399 scrub(&mut out);
400 out
401}
402
403#[must_use]
404pub fn authorize(manifest: &ToolManifest, policy: &PolicySet) -> Authorization {
405 if let Some(list) = &policy.allowlist
406 && !list.iter().any(|n| n == &manifest.name)
407 {
408 return Authorization::Reject;
409 }
410 if manifest.risk > policy.max_risk {
411 return Authorization::Reject;
412 }
413 if manifest.risk >= policy.approval_at {
414 return Authorization::RequireApproval;
415 }
416 Authorization::Execute
417}
Deleted lib/agent-types/src/trace.rs lib/agent-types/src/trace.rs +0 −78
@@ -1,78 +0,0 @@
1//! 全链路追踪标识。
2//!
3//! 之前全仓没有任何跨进程关联键:一次 HTTP 请求产生的事件、被投递到 Redis 队列的
4//! 任务、由 Worker 执行的过程、以及最终落在沙箱里的命令,彼此之间没有任何可串起来的
5//! 标识。线上排查只能靠 `run_id` 手工拼,而 `run_id` 在提交之前并不存在。
6//!
7//! 这里采用最小但完整的方案:**一个 trace id 从接入层一路带到 Worker**,
8//! 中间唯一会丢失上下文的地方(Redis 队列)靠 `JobEnvelope.trace_id` 显式携带。
9//!
10//! 采用 W3C Trace Context 的 trace-id 形态(32 位小写十六进制),
11//! 因此可以直接作为上游 `traceparent` 的 trace id 复用,也能原样塞进 OTLP。
12
13use smol_str::SmolStr;
14
15/// 上游可用于传递 trace id 的请求头(按优先级)。
16///
17/// `x-request-id` 放在前面是因为它更常见且语义更宽松;`traceparent` 需要按
18/// W3C 规范从 `00-<trace-id>-<parent-id>-<flags>` 里取出第 2 段。
19pub const TRACE_ID_HEADERS: [&str; 2] = ["x-request-id", "traceparent"];
20
21/// 把 trace id 回写给调用方的响应头。
22pub const TRACE_ID_RESPONSE_HEADER: &str = "x-request-id";
23
24/// 生成一个新的 trace id:32 位小写十六进制(16 字节 CSPRNG)。
25#[must_use]
26pub fn new_trace_id() -> SmolStr {
27 // 用 u128 一次取满 16 字节随机数,避免循环里拼接带来的可预测性顾虑。
28 let bytes: u128 = rand::random();
29 SmolStr::new(format!("{bytes:032x}"))
30}
31
32/// 是否为合法的 W3C trace-id。
33///
34/// 规范要求:32 位小写十六进制,且**不得全为 0**(全 0 是"无效"的保留值)。
35#[must_use]
36pub fn is_valid_trace_id(raw: &str) -> bool {
37 let raw = raw.trim();
38 raw.len() == 32
39 && raw.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
40 && raw.bytes().any(|b| b != b'0')
41}
42
43/// 从候选头值中解析出 trace id。
44///
45/// 支持两种输入:
46/// - 直接的 32 位 hex(`x-request-id` 的常见形态)
47/// - W3C `traceparent`:`<version>-<trace-id>-<parent-id>-<flags>`
48///
49/// 解析失败或非法时返回 `None`,由调用方决定是否新生成——**不要**把非法值
50/// 原样透传下去,否则整条链路会带着一个无法与上游对齐的假 trace id。
51#[must_use]
52pub fn parse_trace_id(header_name: &str, raw: &str) -> Option<SmolStr> {
53 let raw = raw.trim();
54 if header_name.eq_ignore_ascii_case("traceparent") {
55 let mut parts = raw.split('-');
56 let version = parts.next()?;
57 let trace_id = parts.next()?;
58 // version 00 是当前唯一已定义的版本;长度不符一律视为非法。
59 if version.len() != 2 {
60 return None;
61 }
62 return is_valid_trace_id(trace_id).then(|| SmolStr::new(trace_id));
63 }
64 is_valid_trace_id(raw).then(|| SmolStr::new(raw))
65}
66
67/// 从一组候选头里挑出第一个合法的 trace id。
68#[must_use]
69pub fn trace_id_from_headers<'a>(
70 headers: impl IntoIterator<Item = (&'a str, &'a str)>,
71) -> Option<SmolStr> {
72 for (name, value) in headers {
73 if let Some(id) = parse_trace_id(name, value) {
74 return Some(id);
75 }
76 }
77 None
78}
Deleted lib/agent-types/Cargo.toml lib/agent-types/Cargo.toml +0 −26
@@ -1,26 +0,0 @@
1[package]
2name = "agent-types"
3description = "共享类型与不变量:Run 状态机、AgentEvent、计划、工具、LLM、沙箱、队列、产物、上下文窗口、配额、对外 API DTO。"
4version.workspace = true
5edition.workspace = true
6rust-version = "1.85"
7publish = false
8license = "Apache-2.0"
9
10[dependencies]
11async-trait = { workspace = true }
12chrono = { workspace = true }
13hex = { workspace = true }
14rand = { workspace = true }
15serde = { workspace = true }
16serde_json = { workspace = true }
17sha2 = { workspace = true }
18smol_str = { workspace = true }
19thiserror = { workspace = true }
20uuid = { workspace = true }
21
22[lints.rust]
23unsafe_code = "forbid"
24
25[lints.clippy]
26needless_pass_by_value = "allow"
No newline at end of file
Modified lib/config/src/lib.rs lib/config/src/lib.rs +0 −4 View lib/config/src/lib.rs
@@ -22,7 +22,6 @@
2222pub mod redis;
2323pub mod runner;
2424pub mod s3;
25pub mod sagent;
2625pub mod scoped;
2726pub mod search;
2827pub mod session;
@@ -59,7 +58,6 @@
5958use crate::redis::RedisConfig;
6059use crate::runner::RunnerConfig;
6160use crate::s3::S3Config;
62use crate::sagent::SagentConfig;
6361use crate::search::SearchConfig;
6462use crate::session::SessionConfig;
6563use crate::space::SpaceConfig;
@@ -101,7 +99,6 @@
10199 pub ai: AiConfig,
102100 pub aihost: AiHostServerConfig,
103101 pub push: PushConfig,
104 pub sagent: SagentConfig,
105102}
106103
107104impl AppConfig {
@@ -134,7 +131,6 @@
134131 self.aihost.is_valid().map_err(ConfigError::Validation)?;
135132 self.livekit.is_valid().map_err(ConfigError::Validation)?;
136133 self.push.is_valid().map_err(ConfigError::Validation)?;
137 self.sagent.validate().map_err(ConfigError::Validation)?;
138134 Ok(())
139135 }
140136}
Deleted lib/config/src/sagent.rs lib/config/src/sagent.rs +0 −94
@@ -1,94 +0,0 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct SagentConfig {
6 pub enabled: bool,
7 pub base_url: String,
8 pub admin_key: String,
9 pub session_ttl_secs: u64,
10 pub tenant_id: String,
11 pub max_pagination: u64,
12 pub default_pagination: u64,
13 pub max_collection_name_len: usize,
14 pub max_label_name_len: usize,
15 pub max_title_len: usize,
16 pub max_body_len: usize,
17 pub max_message_len: usize,
18 pub max_mentions_per_message: usize,
19 pub request_timeout_secs: u64,
20 pub subscribe_reconnect_max: u32,
21 pub reconcile_interval_secs: u64,
22}
23
24impl Default for SagentConfig {
25 fn default() -> Self {
26 Self {
27 enabled: false,
28 base_url: "https://agent.gitdata.ai".to_string(),
29 admin_key: String::new(),
30 session_ttl_secs: 600,
31 tenant_id: "3f9a2c1e-77d4-4b18-9c0e-64b2f5a1d001".to_string(),
32 max_pagination: 100,
33 default_pagination: 20,
34 max_collection_name_len: 100,
35 max_label_name_len: 50,
36 max_title_len: 255,
37 max_body_len: 65_535,
38 max_message_len: 65_535,
39 max_mentions_per_message: 20,
40 request_timeout_secs: 60,
41 subscribe_reconnect_max: 5,
42 reconcile_interval_secs: 60,
43 }
44 }
45}
46
47impl SagentConfig {
48 pub fn base(&self) -> String {
49 self.base_url.trim().trim_end_matches('/').to_string()
50 }
51
52 pub fn is_on(&self) -> bool {
53 self.enabled && !self.admin_key.trim().is_empty()
54 }
55
56 pub fn validate(&self) -> Result<(), String> {
57 if !self.enabled {
58 return Ok(());
59 }
60 let base = self.base_url.trim();
61 if base.is_empty() {
62 return Err("sagent.base_url must not be empty when sagent is enabled".to_string());
63 }
64 if !(base.starts_with("http://") || base.starts_with("https://")) {
65 return Err("sagent.base_url must be an http(s) URL".to_string());
66 }
67 if self.admin_key.trim().is_empty() {
68 return Err("sagent.admin_key must not be empty when sagent is enabled".to_string());
69 }
70 if is_blank(&self.tenant_id) {
71 return Err("sagent.tenant_id must not be empty when sagent is enabled".to_string());
72 }
73 if self.session_ttl_secs == 0
74 || self.request_timeout_secs == 0
75 || self.default_pagination == 0
76 || self.reconcile_interval_secs == 0
77 || self.max_collection_name_len == 0
78 || self.max_label_name_len == 0
79 || self.max_title_len == 0
80 || self.max_body_len == 0
81 || self.max_message_len == 0
82 {
83 return Err("sagent limits must be greater than zero".to_string());
84 }
85 if self.default_pagination > self.max_pagination {
86 return Err("sagent.default_pagination must not exceed max_pagination".to_string());
87 }
88 Ok(())
89 }
90}
91
92fn is_blank(value: &str) -> bool {
93 value.trim().is_empty()
94}
Modified lib/config/src/scoped.rs lib/config/src/scoped.rs +1 −5 View lib/config/src/scoped.rs
@@ -25,7 +25,6 @@
2525use crate::quota::QuotaConfig;
2626use crate::redis::RedisConfig;
2727use crate::s3::S3Config;
28use crate::sagent::SagentConfig;
2928use crate::search::SearchConfig;
3029use crate::session::SessionConfig;
3130use crate::space::SpaceConfig;
@@ -216,7 +215,6 @@
216215 pub ai: AiConfig,
217216 pub livekit: LiveKitConfig,
218217 pub push: PushConfig,
219 pub sagent: SagentConfig,
220218}
221219
222220impl ApihostConfig {
@@ -244,7 +242,6 @@
244242 ai: self.ai,
245243 livekit: self.livekit,
246244 push: self.push,
247 sagent: self.sagent,
248245 ..Default::default()
249246 }
250247 }
@@ -266,8 +263,7 @@
266263 self.apihost.is_valid()?;
267264 self.ai.validate()?;
268265 self.livekit.is_valid()?;
269 self.push.is_valid()?;
270 self.sagent.validate()
266 self.push.is_valid()
271267 }
272268}
273269
Deleted lib/models/src/sagent/agent.rs lib/models/src/sagent/agent.rs +0 −122
@@ -1,122 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum AgentState {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Draft,
25 #[sea_orm(num_value = 1)]
26 Pending,
27 #[sea_orm(num_value = 2)]
28 Planning,
29 #[sea_orm(num_value = 3)]
30 Executing,
31 #[sea_orm(num_value = 4)]
32 Waiting,
33 #[sea_orm(num_value = 5)]
34 Succeeded,
35 #[sea_orm(num_value = 6)]
36 Failed,
37 #[sea_orm(num_value = 7)]
38 Canceled,
39 #[sea_orm(num_value = 8)]
40 Closed,
41}
42
43impl AgentState {
44 pub fn is_terminal(self) -> bool {
45 matches!(
46 self,
47 Self::Succeeded | Self::Failed | Self::Canceled | Self::Closed
48 )
49 }
50
51 pub fn is_active(self) -> bool {
52 matches!(
53 self,
54 Self::Pending | Self::Planning | Self::Executing | Self::Waiting
55 )
56 }
57}
58
59#[derive(
60 Debug,
61 Clone,
62 Copy,
63 PartialEq,
64 Eq,
65 Default,
66 EnumIter,
67 DeriveActiveEnum,
68 Serialize,
69 Deserialize,
70 utoipa::ToSchema,
71)]
72#[serde(rename_all = "snake_case")]
73#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
74pub enum AgentPriority {
75 #[default]
76 #[sea_orm(num_value = 0)]
77 None,
78 #[sea_orm(num_value = 1)]
79 Low,
80 #[sea_orm(num_value = 2)]
81 Medium,
82 #[sea_orm(num_value = 3)]
83 High,
84 #[sea_orm(num_value = 4)]
85 Urgent,
86}
87
88#[sea_orm::model]
89#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
90#[sea_orm(table_name = "agent")]
91pub struct Model {
92 #[sea_orm(primary_key, auto_increment = false)]
93 pub id: Uuid,
94 #[sea_orm(unique_key = "collection_number")]
95 pub collection_id: Uuid,
96 #[sea_orm(unique_key = "collection_number")]
97 pub autoincrement_id: i32,
98 pub author_id: Uuid,
99 pub title: String,
100 pub description: Option<String>,
101 pub body: String,
102 #[sea_orm(default_value = 0)]
103 pub state: AgentState,
104 #[sea_orm(default_value = 0)]
105 pub priority: AgentPriority,
106 pub due_at: Option<OffsetDateTime>,
107 pub session_id: Option<Uuid>,
108 pub last_run_id: Option<Uuid>,
109 #[sea_orm(default_value = 0)]
110 pub last_event_seq: i64,
111 pub created_at: OffsetDateTime,
112 pub updated_at: Option<OffsetDateTime>,
113 pub started_at: Option<OffsetDateTime>,
114 pub finished_at: Option<OffsetDateTime>,
115 pub closed_at: Option<OffsetDateTime>,
116 #[sea_orm(belongs_to, from = "collection_id", to = "id")]
117 pub collection: BelongsTo<super::agents::Entity>,
118 #[sea_orm(belongs_to, from = "author_id", to = "uid")]
119 pub author: BelongsTo<super::super::users::user::Entity>,
120}
121
122impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_artifact.rs lib/models/src/sagent/agent_artifact.rs +0 −24
@@ -1,24 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent_artifact")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub agent_id: Uuid,
12 pub run_id: Option<Uuid>,
13 pub kind: String,
14 pub name: String,
15 pub mime: Option<String>,
16 pub size: i64,
17 pub storage_key: String,
18 pub created_by: Option<Uuid>,
19 pub created_at: OffsetDateTime,
20 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
21 pub agent: BelongsTo<super::agent::Entity>,
22}
23
24impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_event_line.rs lib/models/src/sagent/agent_event_line.rs +0 −80
@@ -1,80 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 EnumIter,
13 DeriveActiveEnum,
14 Serialize,
15 Deserialize,
16 utoipa::ToSchema,
17)]
18#[serde(rename_all = "snake_case")]
19#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(32))")]
20pub enum AgentEventType {
21 #[sea_orm(string_value = "created")]
22 Created,
23 #[sea_orm(string_value = "title_updated")]
24 TitleUpdated,
25 #[sea_orm(string_value = "body_updated")]
26 BodyUpdated,
27 #[sea_orm(string_value = "started")]
28 Started,
29 #[sea_orm(string_value = "state_changed")]
30 StateChanged,
31 #[sea_orm(string_value = "message_added")]
32 MessageAdded,
33 #[sea_orm(string_value = "run_failed")]
34 RunFailed,
35 #[sea_orm(string_value = "artifact_added")]
36 ArtifactAdded,
37 #[sea_orm(string_value = "assigned")]
38 Assigned,
39 #[sea_orm(string_value = "unassigned")]
40 Unassigned,
41 #[sea_orm(string_value = "label_added")]
42 LabelAdded,
43 #[sea_orm(string_value = "label_removed")]
44 LabelRemoved,
45 #[sea_orm(string_value = "repo_relation_added")]
46 RepoRelationAdded,
47 #[sea_orm(string_value = "repo_relation_removed")]
48 RepoRelationRemoved,
49 #[sea_orm(string_value = "run_event")]
50 RunEvent,
51 #[sea_orm(string_value = "subscriber_lost")]
52 SubscriberLost,
53 #[sea_orm(string_value = "closed")]
54 Closed,
55 #[sea_orm(string_value = "reopened")]
56 Reopened,
57}
58
59#[sea_orm::model]
60#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
61#[sea_orm(table_name = "agent_event_line")]
62pub struct Model {
63 #[sea_orm(primary_key, auto_increment = false)]
64 pub id: Uuid,
65 pub agent_id: Option<Uuid>,
66 pub collection_id: Option<Uuid>,
67 pub user_id: Option<Uuid>,
68 pub event_type: AgentEventType,
69 pub run_id: Option<Uuid>,
70 pub content: Option<Json>,
71 pub created_at: OffsetDateTime,
72 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
73 pub agent: BelongsTo<Option<super::agent::Entity>>,
74 #[sea_orm(belongs_to, from = "collection_id", to = "id")]
75 pub collection: BelongsTo<Option<super::agents::Entity>>,
76 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
77 pub user: BelongsTo<Option<super::super::users::user::Entity>>,
78}
79
80impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_label.rs lib/models/src/sagent/agent_label.rs +0 −24
@@ -1,24 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent_label")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub dir_id: Uuid,
12 pub name: String,
13 pub exclusive: bool,
14 pub exclusive_order: i32,
15 pub description: Option<String>,
16 pub color: String,
17 pub is_archived: bool,
18 pub created_at: OffsetDateTime,
19 pub updated_at: Option<OffsetDateTime>,
20 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
21 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
22}
23
24impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_member.rs lib/models/src/sagent/agent_member.rs +0 −51
@@ -1,51 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum AgentMemberKind {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Assignee,
25 #[sea_orm(num_value = 1)]
26 Mentioned,
27 #[sea_orm(num_value = 2)]
28 Participant,
29}
30
31#[sea_orm::model]
32#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
33#[sea_orm(table_name = "agent_member")]
34pub struct Model {
35 #[sea_orm(primary_key, auto_increment = false)]
36 pub id: Uuid,
37 #[sea_orm(unique_key = "agent_user_kind")]
38 pub agent_id: Uuid,
39 #[sea_orm(unique_key = "agent_user_kind")]
40 pub user_id: Uuid,
41 #[sea_orm(unique_key = "agent_user_kind")]
42 pub kind: AgentMemberKind,
43 pub created_at: OffsetDateTime,
44 pub updated_at: Option<OffsetDateTime>,
45 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
46 pub agent: BelongsTo<super::agent::Entity>,
47 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
48 pub user: BelongsTo<super::super::users::user::Entity>,
49}
50
51impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_message.rs lib/models/src/sagent/agent_message.rs +0 −55
@@ -1,55 +0,0 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "String", db_type = "String(StringLen::N(16))")]
21pub enum AgentRole {
22 #[default]
23 #[sea_orm(string_value = "user")]
24 User,
25 #[sea_orm(string_value = "agent")]
26 Agent,
27 #[sea_orm(string_value = "system")]
28 System,
29 #[sea_orm(string_value = "tool")]
30 Tool,
31}
32
33#[sea_orm::model]
34#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
35#[sea_orm(table_name = "agent_message")]
36pub struct Model {
37 #[sea_orm(primary_key, auto_increment = false)]
38 pub id: Uuid,
39 #[sea_orm(unique_key = "agent_seq")]
40 pub agent_id: Uuid,
41 #[sea_orm(unique_key = "agent_seq")]
42 pub seq: i64,
43 pub gateway_message_id: Option<Uuid>,
44 pub author_id: Option<Uuid>,
45 pub role: AgentRole,
46 pub content: String,
47 pub created_at: OffsetDateTime,
48 pub updated_at: Option<OffsetDateTime>,
49 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
50 pub agent: BelongsTo<super::agent::Entity>,
51 #[sea_orm(belongs_to, from = "author_id", to = "uid")]
52 pub author: BelongsTo<Option<super::super::users::user::Entity>>,
53}
54
55impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_relation_label.rs lib/models/src/sagent/agent_relation_label.rs +0 −22
@@ -1,22 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent_relation_label")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(unique_key = "agent_label")]
12 pub agent_id: Uuid,
13 #[sea_orm(unique_key = "agent_label")]
14 pub label_id: Uuid,
15 pub created_at: OffsetDateTime,
16 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
17 pub agent: BelongsTo<super::agent::Entity>,
18 #[sea_orm(belongs_to, from = "label_id", to = "id")]
19 pub label: BelongsTo<super::agent_label::Entity>,
20}
21
22impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_relation_repo.rs lib/models/src/sagent/agent_relation_repo.rs +0 −24
@@ -1,24 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent_relation_repo")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(unique_key = "agent_repo")]
12 pub agent_id: Uuid,
13 #[sea_orm(unique_key = "agent_repo")]
14 pub repo_id: Uuid,
15 pub ref_name: Option<String>,
16 pub sub_path: Option<String>,
17 pub created_at: OffsetDateTime,
18 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
19 pub agent: BelongsTo<super::agent::Entity>,
20 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
21 pub repo: BelongsTo<super::super::repo::repos::Entity>,
22}
23
24impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_user.rs lib/models/src/sagent/agent_user.rs +0 −25
@@ -1,25 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agent_user")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(unique_key = "uid_agent")]
12 pub uid: Uuid,
13 #[sea_orm(unique_key = "uid_agent")]
14 pub agent_id: Uuid,
15 #[sea_orm(default_value = "0")]
16 pub last_read_seq: i64,
17 pub created_at: OffsetDateTime,
18 pub updated_at: Option<OffsetDateTime>,
19 #[sea_orm(belongs_to, from = "uid", to = "uid")]
20 pub user: BelongsTo<super::super::users::user::Entity>,
21 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
22 pub agent: BelongsTo<super::agent::Entity>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agent_watch.rs lib/models/src/sagent/agent_watch.rs +0 −25
@@ -1,25 +0,0 @@
1use crate::common::watch_mode::WatchMode;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
8#[sea_orm(table_name = "agent_watch")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 #[sea_orm(unique_key = "agent_user")]
13 pub agent_id: Uuid,
14 #[sea_orm(unique_key = "agent_user")]
15 pub user_id: Uuid,
16 pub mode: WatchMode,
17 pub created_at: OffsetDateTime,
18 pub updated_at: Option<OffsetDateTime>,
19 #[sea_orm(belongs_to, from = "agent_id", to = "id")]
20 pub agent: BelongsTo<super::agent::Entity>,
21 #[sea_orm(belongs_to, from = "user_id", to = "uid")]
22 pub user: BelongsTo<super::super::users::user::Entity>,
23}
24
25impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/agents.rs lib/models/src/sagent/agents.rs +0 −22
@@ -1,22 +0,0 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "agents")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub dir_id: Uuid,
12 pub name: String,
13 pub icon: Option<String>,
14 pub description: Option<String>,
15 pub config: Option<Json>,
16 pub created_at: OffsetDateTime,
17 pub updated_at: Option<OffsetDateTime>,
18 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
19 pub dir: BelongsTo<super::super::dir::dirs::Entity>,
20}
21
22impl ActiveModelBehavior for ActiveModel {}
Deleted lib/models/src/sagent/mod.rs lib/models/src/sagent/mod.rs +0 −13
@@ -1,13 +0,0 @@
1#![allow(clippy::module_inception)]
2
3pub mod agent;
4pub mod agent_artifact;
5pub mod agent_event_line;
6pub mod agent_label;
7pub mod agent_member;
8pub mod agent_message;
9pub mod agent_relation_label;
10pub mod agent_relation_repo;
11pub mod agent_user;
12pub mod agent_watch;
13pub mod agents;
Modified lib/models/src/lib.rs lib/models/src/lib.rs +0 −1 View lib/models/src/lib.rs
@@ -12,7 +12,6 @@
1212pub mod pull_request;
1313pub mod repo;
1414pub mod room;
15pub mod sagent;
1615pub mod search;
1716pub mod space;
1817pub mod system;
Modified lib/models/schema_extras.sql lib/models/schema_extras.sql +0 −43 View lib/models/schema_extras.sql
@@ -299,46 +299,3 @@
299299
300300CREATE INDEX IF NOT EXISTS idx_ci_runner_state
301301 ON ci_runner (state, last_seen_at DESC);
302
303-- ---------------------------------------------------------------------------
304-- Sagent domain (lib/sagent): target guard for polymorphic event lines, plus
305-- lookup indexes.
306--
307-- Caveat: SeaORM sync drops any unique index on a managed table whose column
308-- list does not match an entity-declared unique key, and it does so with
309-- ALTER TABLE ... DROP CONSTRAINT, which fails on plain CREATE UNIQUE INDEX.
310-- So only NON-unique indexes (or partial variants of a declared unique key)
311-- may be added here.
312-- ---------------------------------------------------------------------------
313-- Every activity line belongs to a task, and records its collection too so the
314-- collection feed needs no join. "At least one target" (not XOR) is what that
315-- shape allows; a line with neither is still an orphan and is rejected.
316-- The constraint is dropped first so an older definition is replaced instead of
317-- being skipped by the existence check.
318DO $$ BEGIN
319 ALTER TABLE agent_event_line DROP CONSTRAINT IF EXISTS agent_event_target_xor;
320 ALTER TABLE agent_event_line
321 ADD CONSTRAINT agent_event_target_xor
322 CHECK (agent_id IS NOT NULL OR collection_id IS NOT NULL);
323END $$;
324
325CREATE INDEX IF NOT EXISTS idx_agent_collection_state
326 ON agent (collection_id, state);
327CREATE INDEX IF NOT EXISTS idx_agent_active
328 ON agent (state) WHERE state IN (1, 2, 3, 4);
329CREATE INDEX IF NOT EXISTS idx_agent_message_agent_created
330 ON agent_message (agent_id, created_at DESC);
331CREATE INDEX IF NOT EXISTS idx_agent_event_line_agent_created
332 ON agent_event_line (agent_id, created_at DESC);
333CREATE INDEX IF NOT EXISTS idx_agent_label_dir
334 ON agent_label (dir_id, is_archived);
335CREATE INDEX IF NOT EXISTS idx_agent_relation_label_label
336 ON agent_relation_label (label_id);
337CREATE INDEX IF NOT EXISTS idx_agent_member_user
338 ON agent_member (user_id, kind);
339CREATE INDEX IF NOT EXISTS idx_agent_user_agent
340 ON agent_user (agent_id);
341CREATE INDEX IF NOT EXISTS idx_agent_relation_repo_repo
342 ON agent_relation_repo (repo_id);
343CREATE INDEX IF NOT EXISTS idx_agent_artifact_agent_created
344 ON agent_artifact (agent_id, created_at DESC);
Deleted lib/sagent/src/gateway/mod.rs lib/sagent/src/gateway/mod.rs +0 −256
@@ -1,256 +0,0 @@
1use std::future::Future;
2use std::time::Duration;
3
4use agent_sdk::{AdminClient, Client, EventStream, SdkError};
5use agent_types::api::{Page, RunDto};
6use agent_types::ids::{RunId, SessionId, TenantId};
7use agent_types::session::{MessageAccepted, MessageDto, SessionDto, SessionTokenDto};
8use config::sagent::SagentConfig;
9use models::sagent::agent::AgentState;
10use smol_str::SmolStr;
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use agent_types::state::RunState;
15
16use crate::errors::SagentError;
17
18pub const INVOKE_SCOPES: [&str; 6] = [
19 "sessions:read",
20 "sessions:write",
21 "sessions:events:subscribe",
22 "runs:read",
23 "runs:write",
24 "runs:cancel",
25];
26
27pub struct AgentGateway {
28 admin: AdminClient,
29 base: String,
30 tenant: TenantId,
31 token_ttl_secs: u64,
32 timeout: Duration,
33}
34
35impl AgentGateway {
36 pub fn new(cfg: &SagentConfig) -> Result<Self, SagentError> {
37 if !cfg.is_on() {
38 return Err(SagentError::Unavailable("sagent is not enabled".into()));
39 }
40 let base = cfg.base();
41 let admin = AdminClient::new(&base, cfg.admin_key.clone())?;
42 let tenant = Uuid::parse_str(cfg.tenant_id.trim())
43 .map_err(|_| SagentError::BadRequest("sagent tenant id is not a uuid".into()))?;
44 Ok(Self {
45 admin,
46 base,
47 tenant: TenantId::from_uuid(tenant),
48 token_ttl_secs: cfg.session_ttl_secs,
49 timeout: Duration::from_secs(cfg.request_timeout_secs.max(1)),
50 })
51 }
52
53 pub fn base(&self) -> &str {
54 &self.base
55 }
56
57 pub fn tenant(&self) -> TenantId {
58 self.tenant
59 }
60
61 pub fn token_ttl_secs(&self) -> u64 {
62 self.token_ttl_secs
63 }
64
65 async fn timed<T>(
66 &self,
67 future: impl Future<Output = Result<T, SdkError>>,
68 ) -> Result<T, SagentError> {
69 match tokio::time::timeout(self.timeout, future).await {
70 Ok(result) => result.map_err(SagentError::from),
71 Err(_) => Err(SagentError::Unavailable(
72 "agent gateway request timed out".into(),
73 )),
74 }
75 }
76
77 pub async fn ensure_session(
78 &self,
79 existing: Option<Uuid>,
80 title: &str,
81 metadata: Option<serde_json::Value>,
82 ) -> Result<SessionDto, SagentError> {
83 if let Some(id) = existing {
84 match self
85 .timed(self.admin.get_session(SessionId::from_uuid(id)))
86 .await
87 {
88 Ok(session) => return Ok(session),
89 Err(SagentError::NotFound) => {}
90 Err(error) => return Err(error),
91 }
92 }
93 self.timed(self.admin.create_session(None, Some(title), metadata))
94 .await
95 }
96
97 pub async fn list_session_messages(
98 &self,
99 session_id: Uuid,
100 limit: Option<u32>,
101 cursor: Option<u64>,
102 ) -> Result<Page<MessageDto>, SagentError> {
103 self.timed(
104 self.admin
105 .list_messages(SessionId::from_uuid(session_id), limit, cursor),
106 )
107 .await
108 }
109
110 pub async fn open_session(
111 &self,
112 session_id: Uuid,
113 subject: Uuid,
114 scopes: &[&str],
115 ) -> Result<SessionHandle, SagentError> {
116 let session = SessionId::from_uuid(session_id);
117 let token = self
118 .timed(self.admin.issue_session_token_with_scopes(
119 session,
120 self.tenant,
121 &subject.to_string(),
122 Some(self.token_ttl_secs),
123 Some(scopes.to_vec()),
124 ))
125 .await?;
126 let expires_in = token.expires_in_secs.min(i64::MAX as u64) as i64;
127 let expires_at = OffsetDateTime::now_utc() + time::Duration::seconds(expires_in);
128 let client = Client::new(&self.base, token.token.to_string(), session)?;
129 Ok(SessionHandle {
130 client,
131 session,
132 token,
133 expires_at,
134 })
135 }
136}
137
138pub struct SessionHandle {
139 client: Client,
140 session: SessionId,
141 token: SessionTokenDto,
142 expires_at: OffsetDateTime,
143}
144
145impl SessionHandle {
146 pub fn session_id(&self) -> Uuid {
147 self.session.as_uuid()
148 }
149
150 pub fn token(&self) -> &str {
151 self.token.token.as_str()
152 }
153
154 pub fn grant_id(&self) -> Uuid {
155 self.token.grant_id.as_uuid()
156 }
157
158 pub fn expires_in_secs(&self) -> u64 {
159 self.token.expires_in_secs
160 }
161
162 pub fn expires_at(&self) -> OffsetDateTime {
163 self.expires_at
164 }
165
166 pub fn is_expired(&self) -> bool {
167 OffsetDateTime::now_utc() >= self.expires_at
168 }
169
170 pub async fn get_session(&self) -> Result<SessionDto, SagentError> {
171 self.client.get_session().await.map_err(SagentError::from)
172 }
173
174 pub async fn list_messages(
175 &self,
176 limit: Option<u32>,
177 cursor: Option<u64>,
178 ) -> Result<Page<MessageDto>, SagentError> {
179 self.client
180 .list_messages(limit, cursor)
181 .await
182 .map_err(SagentError::from)
183 }
184
185 pub async fn send_message(
186 &self,
187 text: impl Into<SmolStr>,
188 idempotency_key: Option<SmolStr>,
189 ) -> Result<MessageAccepted, SagentError> {
190 self.client
191 .send_message(text, idempotency_key)
192 .await
193 .map_err(SagentError::from)
194 }
195
196 pub async fn get_run(&self, run_id: Uuid) -> Result<RunDto, SagentError> {
197 self.client
198 .get_run(RunId::from_uuid(run_id))
199 .await
200 .map_err(SagentError::from)
201 }
202
203 pub async fn cancel_run(&self, run_id: Uuid) -> Result<RunDto, SagentError> {
204 self.client
205 .cancel_run(RunId::from_uuid(run_id))
206 .await
207 .map_err(SagentError::from)
208 }
209
210 pub async fn stream_events(
211 &self,
212 last_event_id: Option<u64>,
213 ) -> Result<EventStream, SagentError> {
214 self.client
215 .stream_events(last_event_id)
216 .await
217 .map_err(SagentError::from)
218 }
219}
220
221pub fn map_run_state(state: RunState) -> AgentState {
222 match state {
223 RunState::Pending => AgentState::Pending,
224 RunState::Planning => AgentState::Planning,
225 RunState::Executing => AgentState::Executing,
226 RunState::AwaitingApproval | RunState::Verifying | RunState::Deferred => {
227 AgentState::Waiting
228 }
229 RunState::Completed => AgentState::Succeeded,
230 RunState::Failed => AgentState::Failed,
231 RunState::Cancelled => AgentState::Canceled,
232 }
233}
234
235impl From<SdkError> for SagentError {
236 fn from(error: SdkError) -> Self {
237 match error {
238 SdkError::Transport(message) | SdkError::Decode(message) => Self::Unavailable(message),
239 SdkError::RateLimited(message) => Self::RateLimited(message),
240 SdkError::NotFound(_) | SdkError::Gone(_) => Self::NotFound,
241 SdkError::Conflict(message) => Self::Conflict(message),
242 SdkError::BadRequest(message) => Self::BadRequest(message),
243 SdkError::Unauthorized(message) | SdkError::Forbidden(message) => {
244 Self::Upstream(message)
245 }
246 SdkError::Invalid(message) => Self::Internal(message),
247 SdkError::Server { status, message } => {
248 if status >= 500 {
249 Self::Unavailable(message)
250 } else {
251 Self::Upstream(message)
252 }
253 }
254 }
255 }
256}
Deleted lib/sagent/src/handlers/artifacts/get_artifact.rs lib/sagent/src/handlers/artifacts/get_artifact.rs +0 −40
@@ -1,40 +0,0 @@
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::artifacts::AgentArtifactDto;
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts/{artifact_id}",
14 tag = "sagent",
15 description = "读取单个产物元数据与临时下载链接。",
16 params(AgentPath, crate::services::artifacts::TaskArtifactPath),
17 responses(
18 (status = 200, description = "get artifact", body = AppResponseData<AgentArtifactDto>),
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 get_artifact(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<crate::services::artifacts::TaskArtifactPath>,
28) -> Result<AppResponseData<AgentArtifactDto>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .get_artifact(
32 uid,
33 &path.space_name,
34 path.collection_id,
35 &path.task_id,
36 path.artifact_id,
37 )
38 .await?;
39 Ok(AppResponseData::ok(dto))
40}
Deleted lib/sagent/src/handlers/artifacts/list_artifacts.rs lib/sagent/src/handlers/artifacts/list_artifacts.rs +0 −41
@@ -1,41 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::artifacts::{AgentArtifactPage, ListAgentArtifactQuery};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts",
14 tag = "sagent",
15 description = "分页列出 run 产出的产物,返回临时下载链接。",
16 params(AgentPath, ListAgentArtifactQuery),
17 responses(
18 (status = 200, description = "list artifacts", body = AppResponseData<AgentArtifactPage>),
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_artifacts(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28 Query(query): Query<ListAgentArtifactQuery>,
29) -> Result<AppResponseData<AgentArtifactPage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_artifacts(
33 uid,
34 &path.space_name,
35 path.collection_id,
36 &path.task_id,
37 query,
38 )
39 .await?;
40 Ok(AppResponseData::ok(page))
41}
Deleted lib/sagent/src/handlers/artifacts/mod.rs lib/sagent/src/handlers/artifacts/mod.rs +0 −2
@@ -1,2 +0,0 @@
1pub mod get_artifact;
2pub mod list_artifacts;
Deleted lib/sagent/src/handlers/collections/create_collection.rs lib/sagent/src/handlers/collections/create_collection.rs +0 −37
@@ -1,37 +0,0 @@
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}
Deleted lib/sagent/src/handlers/collections/delete_collection.rs lib/sagent/src/handlers/collections/delete_collection.rs +0 −33
@@ -1,33 +0,0 @@
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}
Deleted lib/sagent/src/handlers/collections/get_collection.rs lib/sagent/src/handlers/collections/get_collection.rs +0 −33
@@ -1,33 +0,0 @@
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}
Deleted lib/sagent/src/handlers/collections/list_collections.rs lib/sagent/src/handlers/collections/list_collections.rs +0 −35
@@ -1,35 +0,0 @@
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}
Deleted lib/sagent/src/handlers/collections/mod.rs lib/sagent/src/handlers/collections/mod.rs +0 −5
@@ -1,5 +0,0 @@
1pub mod create_collection;
2pub mod delete_collection;
3pub mod get_collection;
4pub mod list_collections;
5pub mod update_collection;
Deleted lib/sagent/src/handlers/collections/update_collection.rs lib/sagent/src/handlers/collections/update_collection.rs +0 −36
@@ -1,36 +0,0 @@
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}
Deleted lib/sagent/src/handlers/events/list_events.rs lib/sagent/src/handlers/events/list_events.rs +0 −41
@@ -1,41 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::events::{AgentEventPage, ListAgentEventQuery};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/events",
14 tag = "sagent",
15 description = "读取任务活动流,含状态迁移、run 事件与订阅中断记录,按时间倒序分页。",
16 params(AgentPath, ListAgentEventQuery),
17 responses(
18 (status = 200, description = "list events", body = AppResponseData<AgentEventPage>),
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_events(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28 Query(query): Query<ListAgentEventQuery>,
29) -> Result<AppResponseData<AgentEventPage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_events(
33 uid,
34 &path.space_name,
35 path.collection_id,
36 &path.task_id,
37 query,
38 )
39 .await?;
40 Ok(AppResponseData::ok(page))
41}
Deleted lib/sagent/src/handlers/events/mod.rs lib/sagent/src/handlers/events/mod.rs +0 −1
@@ -1 +0,0 @@
1pub mod list_events;
Deleted lib/sagent/src/handlers/labels/bind_label.rs lib/sagent/src/handlers/labels/bind_label.rs +0 −40
@@ -1,40 +0,0 @@
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::labels::TaskLabelPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels/{label_id}",
13 tag = "sagent",
14 description = "给任务绑定标签,并在活动流记录 label_added。",
15 params(TaskLabelPath),
16 responses(
17 (status = 200, description = "bind label", 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 (status = 409, description = "label already bound", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn bind_label(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<TaskLabelPath>,
28) -> Result<AppResponseData<NoData>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 service
31 .bind_label(
32 uid,
33 &path.space_name,
34 path.collection_id,
35 &path.task_id,
36 path.label_id,
37 )
38 .await?;
39 Ok(AppResponseData::ok(NoData))
40}
Deleted lib/sagent/src/handlers/labels/create_label.rs lib/sagent/src/handlers/labels/create_label.rs +0 −37
@@ -1,37 +0,0 @@
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::labels::{AgentLabelDto, CreateAgentLabelCmd};
8use crate::services::service::SagentService;
9use crate::services::tasks::CollectionRef;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections/{collection_id}/labels",
14 tag = "sagent",
15 description = "在智能体集所属目录下创建标签,标签挂在目录而非单个任务上。",
16 params(CollectionRef),
17 request_body = CreateAgentLabelCmd,
18 responses(
19 (status = 200, description = "create label", body = AppResponseData<AgentLabelDto>),
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_label(
27 State(service): State<SagentService>,
28 session: Session,
29 Path(path): Path<CollectionRef>,
30 Json(cmd): Json<CreateAgentLabelCmd>,
31) -> Result<AppResponseData<AgentLabelDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .create_label(uid, &path.space_name, path.collection_id, cmd)
35 .await?;
36 Ok(AppResponseData::ok(dto))
37}
Deleted lib/sagent/src/handlers/labels/delete_label.rs lib/sagent/src/handlers/labels/delete_label.rs +0 −33
@@ -1,33 +0,0 @@
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::labels::AgentLabelPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}/labels/{label_id}",
13 tag = "sagent",
14 description = "删除智能体标签并解除其与所有任务的绑定。",
15 params(AgentLabelPath),
16 responses(
17 (status = 200, description = "delete label", 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_label(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<AgentLabelPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .delete_label(uid, &path.space_name, path.collection_id, path.label_id)
31 .await?;
32 Ok(AppResponseData::ok(NoData))
33}
Deleted lib/sagent/src/handlers/labels/get_label.rs lib/sagent/src/handlers/labels/get_label.rs +0 −33
@@ -1,33 +0,0 @@
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::labels::{AgentLabelDto, AgentLabelPath};
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 get,
12 path = "/agents/{space_name}/collections/{collection_id}/labels/{label_id}",
13 tag = "sagent",
14 description = "读取单个智能体标签。",
15 params(AgentLabelPath),
16 responses(
17 (status = 200, description = "get label", body = AppResponseData<AgentLabelDto>),
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_label(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<AgentLabelPath>,
27) -> Result<AppResponseData<AgentLabelDto>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 let dto = service
30 .get_label(uid, &path.space_name, path.collection_id, path.label_id)
31 .await?;
32 Ok(AppResponseData::ok(dto))
33}
Deleted lib/sagent/src/handlers/labels/list_labels.rs lib/sagent/src/handlers/labels/list_labels.rs +0 −35
@@ -1,35 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::labels::{AgentLabelPage, ListAgentLabelQuery};
8use crate::services::service::SagentService;
9use crate::services::tasks::CollectionRef;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/labels",
14 tag = "sagent",
15 description = "分页列出智能体集标签,可按关键字与归档状态过滤。",
16 params(CollectionRef, ListAgentLabelQuery),
17 responses(
18 (status = 200, description = "list labels", body = AppResponseData<AgentLabelPage>),
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_labels(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<CollectionRef>,
28 Query(query): Query<ListAgentLabelQuery>,
29) -> Result<AppResponseData<AgentLabelPage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_labels(uid, &path.space_name, path.collection_id, query)
33 .await?;
34 Ok(AppResponseData::ok(page))
35}
Deleted lib/sagent/src/handlers/labels/list_task_labels.rs lib/sagent/src/handlers/labels/list_task_labels.rs +0 −34
@@ -1,34 +0,0 @@
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::labels::AgentLabelDto;
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels",
14 tag = "sagent",
15 description = "列出任务上已绑定的标签。",
16 params(AgentPath),
17 responses(
18 (status = 200, description = "list task labels", body = AppResponseData<Vec<AgentLabelDto>>),
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_task_labels(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28) -> Result<AppResponseData<Vec<AgentLabelDto>>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let labels = service
31 .list_task_labels(uid, &path.space_name, path.collection_id, &path.task_id)
32 .await?;
33 Ok(AppResponseData::ok(labels))
34}
Deleted lib/sagent/src/handlers/labels/mod.rs lib/sagent/src/handlers/labels/mod.rs +0 −8
@@ -1,8 +0,0 @@
1pub mod bind_label;
2pub mod create_label;
3pub mod delete_label;
4pub mod get_label;
5pub mod list_labels;
6pub mod list_task_labels;
7pub mod unbind_label;
8pub mod update_label;
Deleted lib/sagent/src/handlers/labels/unbind_label.rs lib/sagent/src/handlers/labels/unbind_label.rs +0 −39
@@ -1,39 +0,0 @@
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::labels::TaskLabelPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels/{label_id}",
13 tag = "sagent",
14 description = "解除任务上的标签绑定,并在活动流记录 label_removed。",
15 params(TaskLabelPath),
16 responses(
17 (status = 200, description = "unbind label", 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 unbind_label(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<TaskLabelPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .unbind_label(
31 uid,
32 &path.space_name,
33 path.collection_id,
34 &path.task_id,
35 path.label_id,
36 )
37 .await?;
38 Ok(AppResponseData::ok(NoData))
39}
Deleted lib/sagent/src/handlers/labels/update_label.rs lib/sagent/src/handlers/labels/update_label.rs +0 −42
@@ -1,42 +0,0 @@
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::labels::{AgentLabelDto, AgentLabelPath, UpdateAgentLabelCmd};
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 patch,
12 path = "/agents/{space_name}/collections/{collection_id}/labels/{label_id}",
13 tag = "sagent",
14 description = "更新智能体标签名称、颜色、独占标记或归档状态。",
15 params(AgentLabelPath),
16 request_body = UpdateAgentLabelCmd,
17 responses(
18 (status = 200, description = "update label", body = AppResponseData<AgentLabelDto>),
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_label(
26 State(service): State<SagentService>,
27 session: Session,
28 Path(path): Path<AgentLabelPath>,
29 Json(cmd): Json<UpdateAgentLabelCmd>,
30) -> Result<AppResponseData<AgentLabelDto>, SagentApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .update_label(
34 uid,
35 &path.space_name,
36 path.collection_id,
37 path.label_id,
38 cmd,
39 )
40 .await?;
41 Ok(AppResponseData::ok(dto))
42}
Deleted lib/sagent/src/handlers/members/create_member.rs lib/sagent/src/handlers/members/create_member.rs +0 −44
@@ -1,44 +0,0 @@
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::members::{AgentMemberDto, CreateAgentMemberCmd};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members",
14 tag = "sagent",
15 description = "给任务添加协作人;指派(assignee)会通知被指派者。",
16 params(AgentPath),
17 request_body = CreateAgentMemberCmd,
18 responses(
19 (status = 200, description = "create member", body = AppResponseData<AgentMemberDto>),
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 (status = 409, description = "member already exists", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn create_member(
28 State(service): State<SagentService>,
29 session: Session,
30 Path(path): Path<AgentPath>,
31 Json(cmd): Json<CreateAgentMemberCmd>,
32) -> Result<AppResponseData<AgentMemberDto>, SagentApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .create_member(
36 uid,
37 &path.space_name,
38 path.collection_id,
39 &path.task_id,
40 cmd,
41 )
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Deleted lib/sagent/src/handlers/members/delete_member.rs lib/sagent/src/handlers/members/delete_member.rs +0 −39
@@ -1,39 +0,0 @@
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::members::TaskMemberPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members/{user_id}",
13 tag = "sagent",
14 description = "移除任务协作人,并在活动流记录 unassigned。",
15 params(TaskMemberPath),
16 responses(
17 (status = 200, description = "delete member", 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_member(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<TaskMemberPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .delete_member(
31 uid,
32 &path.space_name,
33 path.collection_id,
34 &path.task_id,
35 path.user_id,
36 )
37 .await?;
38 Ok(AppResponseData::ok(NoData))
39}
Deleted lib/sagent/src/handlers/members/list_members.rs lib/sagent/src/handlers/members/list_members.rs +0 −41
@@ -1,41 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::members::{AgentMemberPage, ListAgentMemberQuery};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members",
14 tag = "sagent",
15 description = "分页列出任务协作人,可按角色过滤。",
16 params(AgentPath, ListAgentMemberQuery),
17 responses(
18 (status = 200, description = "list members", body = AppResponseData<AgentMemberPage>),
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_members(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28 Query(query): Query<ListAgentMemberQuery>,
29) -> Result<AppResponseData<AgentMemberPage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_members(
33 uid,
34 &path.space_name,
35 path.collection_id,
36 &path.task_id,
37 query,
38 )
39 .await?;
40 Ok(AppResponseData::ok(page))
41}
Deleted lib/sagent/src/handlers/members/mod.rs lib/sagent/src/handlers/members/mod.rs +0 −3
@@ -1,3 +0,0 @@
1pub mod create_member;
2pub mod delete_member;
3pub mod list_members;
Deleted lib/sagent/src/handlers/messages/list_messages.rs lib/sagent/src/handlers/messages/list_messages.rs +0 −41
@@ -1,41 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::messages::{AgentMessagePage, ListMessageQuery};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/messages",
14 tag = "sagent",
15 description = "读取任务的对话镜像(本地落库),按网关序号升序分页。用户回复由浏览器直连网关发出,再由落库订阅者写回此表。",
16 params(AgentPath, ListMessageQuery),
17 responses(
18 (status = 200, description = "list messages", body = AppResponseData<AgentMessagePage>),
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_messages(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28 Query(query): Query<ListMessageQuery>,
29) -> Result<AppResponseData<AgentMessagePage>, SagentApiError> {
30 let uid = require_uid(&session)?;
31 let page = service
32 .list_messages(
33 uid,
34 &path.space_name,
35 path.collection_id,
36 &path.task_id,
37 query,
38 )
39 .await?;
40 Ok(AppResponseData::ok(page))
41}
Deleted lib/sagent/src/handlers/messages/mod.rs lib/sagent/src/handlers/messages/mod.rs +0 −1
@@ -1 +0,0 @@
1pub mod list_messages;
Deleted lib/sagent/src/handlers/reads/get_read_state.rs lib/sagent/src/handlers/reads/get_read_state.rs +0 −34
@@ -1,34 +0,0 @@
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::reads::AgentReadStateDto;
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/read-state",
14 tag = "sagent",
15 description = "读取当前用户在该任务的已读水位与未读数。",
16 params(AgentPath),
17 responses(
18 (status = 200, description = "get read state", body = AppResponseData<AgentReadStateDto>),
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 get_read_state(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28) -> Result<AppResponseData<AgentReadStateDto>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .get_read_state(uid, &path.space_name, path.collection_id, &path.task_id)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Deleted lib/sagent/src/handlers/reads/mod.rs lib/sagent/src/handlers/reads/mod.rs +0 −2
@@ -1,2 +0,0 @@
1pub mod get_read_state;
2pub mod update_read_state;
Deleted lib/sagent/src/handlers/reads/update_read_state.rs lib/sagent/src/handlers/reads/update_read_state.rs +0 −43
@@ -1,43 +0,0 @@
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::reads::{AgentReadStateDto, UpdateReadStateCmd};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 put,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/read-state",
14 tag = "sagent",
15 description = "推进已读水位;省略 last_read_seq 时以任务当前事件序号为水位。",
16 params(AgentPath),
17 request_body = UpdateReadStateCmd,
18 responses(
19 (status = 200, description = "update read state", body = AppResponseData<AgentReadStateDto>),
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 update_read_state(
27 State(service): State<SagentService>,
28 session: Session,
29 Path(path): Path<AgentPath>,
30 Json(cmd): Json<UpdateReadStateCmd>,
31) -> Result<AppResponseData<AgentReadStateDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .mark_read(
35 uid,
36 &path.space_name,
37 path.collection_id,
38 &path.task_id,
39 cmd,
40 )
41 .await?;
42 Ok(AppResponseData::ok(dto))
43}
Deleted lib/sagent/src/handlers/repo_relations/create_repo_relation.rs lib/sagent/src/handlers/repo_relations/create_repo_relation.rs +0 −44
@@ -1,44 +0,0 @@
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::repo_relations::{AgentRepoRelDto, CreateAgentRepoRelCmd};
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 post,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos",
14 tag = "sagent",
15 description = "把仓库关联到任务,作为沙箱签出目标;可指定分支与子路径。",
16 params(AgentPath),
17 request_body = CreateAgentRepoRelCmd,
18 responses(
19 (status = 200, description = "create repo relation", body = AppResponseData<AgentRepoRelDto>),
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 (status = 409, description = "repo relation already exists", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn create_repo_relation(
28 State(service): State<SagentService>,
29 session: Session,
30 Path(path): Path<AgentPath>,
31 Json(cmd): Json<CreateAgentRepoRelCmd>,
32) -> Result<AppResponseData<AgentRepoRelDto>, SagentApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .create_repo_relation(
36 uid,
37 &path.space_name,
38 path.collection_id,
39 &path.task_id,
40 cmd,
41 )
42 .await?;
43 Ok(AppResponseData::ok(dto))
44}
Deleted lib/sagent/src/handlers/repo_relations/delete_repo_relation.rs lib/sagent/src/handlers/repo_relations/delete_repo_relation.rs +0 −39
@@ -1,39 +0,0 @@
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::repo_relations::TaskRepoRelPath;
8use crate::services::service::SagentService;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos/{rel_id}",
13 tag = "sagent",
14 description = "解除任务与仓库的关联。",
15 params(TaskRepoRelPath),
16 responses(
17 (status = 200, description = "delete repo relation", 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_repo_relation(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<TaskRepoRelPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .delete_repo_relation(
31 uid,
32 &path.space_name,
33 path.collection_id,
34 &path.task_id,
35 path.rel_id,
36 )
37 .await?;
38 Ok(AppResponseData::ok(NoData))
39}
Deleted lib/sagent/src/handlers/repo_relations/list_repo_relations.rs lib/sagent/src/handlers/repo_relations/list_repo_relations.rs +0 −34
@@ -1,34 +0,0 @@
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::repo_relations::AgentRepoRelDto;
8use crate::services::service::SagentService;
9use crate::services::tasks::AgentPath;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos",
14 tag = "sagent",
15 description = "列出任务已关联的仓库。",
16 params(AgentPath),
17 responses(
18 (status = 200, description = "list repo relations", body = AppResponseData<Vec<AgentRepoRelDto>>),
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_repo_relations(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28) -> Result<AppResponseData<Vec<AgentRepoRelDto>>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let items = service
31 .list_repo_relations(uid, &path.space_name, path.collection_id, &path.task_id)
32 .await?;
33 Ok(AppResponseData::ok(items))
34}
Deleted lib/sagent/src/handlers/repo_relations/mod.rs lib/sagent/src/handlers/repo_relations/mod.rs +0 −3
@@ -1,3 +0,0 @@
1pub mod create_repo_relation;
2pub mod delete_repo_relation;
3pub mod list_repo_relations;
Deleted lib/sagent/src/handlers/tasks/cancel_task.rs lib/sagent/src/handlers/tasks/cancel_task.rs +0 −37
@@ -1,37 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath};
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/cancel",
13 tag = "sagent",
14 description = "取消任务当前 run,并把任务状态置为已取消。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "cancel task", body = AppResponseData<AgentDto>),
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 (status = 409, description = "task has no run", body = AppResponseData<NoData>),
22 (status = 429, description = "gateway rate limited", body = AppResponseData<NoData>),
23 (status = 502, description = "gateway rejected the request", body = AppResponseData<NoData>),
24 (status = 503, description = "gateway unavailable", body = AppResponseData<NoData>)
25 )
26)]
27pub async fn cancel_task(
28 State(service): State<SagentService>,
29 session: Session,
30 Path(path): Path<AgentPath>,
31) -> Result<AppResponseData<AgentDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .cancel_task(uid, &path.space_name, path.collection_id, &path.task_id)
35 .await?;
36 Ok(AppResponseData::ok(dto))
37}
Deleted lib/sagent/src/handlers/tasks/close_task.rs lib/sagent/src/handlers/tasks/close_task.rs +0 −34
@@ -1,34 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath};
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/close",
13 tag = "sagent",
14 description = "关闭任务,记录关闭时间。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "close task", body = AppResponseData<AgentDto>),
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 (status = 409, description = "task is already closed", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn close_task(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28) -> Result<AppResponseData<AgentDto>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .close_task(uid, &path.space_name, path.collection_id, &path.task_id)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Deleted lib/sagent/src/handlers/tasks/create_task.rs lib/sagent/src/handlers/tasks/create_task.rs +0 −36
@@ -1,36 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, CollectionRef, CreateAgentCmd};
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks",
13 tag = "sagent",
14 description = "在智能体集下创建任务,自动分配集合内编号,初始状态为草稿。",
15 params(CollectionRef),
16 request_body = CreateAgentCmd,
17 responses(
18 (status = 200, description = "create task", body = AppResponseData<AgentDto>),
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 create_task(
26 State(service): State<SagentService>,
27 session: Session,
28 Path(path): Path<CollectionRef>,
29 Json(cmd): Json<CreateAgentCmd>,
30) -> Result<AppResponseData<AgentDto>, SagentApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_task(uid, &path.space_name, path.collection_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Deleted lib/sagent/src/handlers/tasks/delete_task.rs lib/sagent/src/handlers/tasks/delete_task.rs +0 −33
@@ -1,33 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::AgentPath;
9
10#[utoipa::path(
11 delete,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}",
13 tag = "sagent",
14 description = "删除任务并级联清除其消息、关注与活动流。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "delete task", 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_task(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<AgentPath>,
27) -> Result<AppResponseData<NoData>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 service
30 .delete_task(uid, &path.space_name, path.collection_id, &path.task_id)
31 .await?;
32 Ok(AppResponseData::ok(NoData))
33}
Deleted lib/sagent/src/handlers/tasks/get_task.rs lib/sagent/src/handlers/tasks/get_task.rs +0 −33
@@ -1,33 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath};
9
10#[utoipa::path(
11 get,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}",
13 tag = "sagent",
14 description = "读取任务详情,task_id 支持 UUID 或集合内编号。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "get task", body = AppResponseData<AgentDto>),
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_task(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<AgentPath>,
27) -> Result<AppResponseData<AgentDto>, SagentApiError> {
28 let uid = require_uid(&session)?;
29 let dto = service
30 .get_task(uid, &path.space_name, path.collection_id, &path.task_id)
31 .await?;
32 Ok(AppResponseData::ok(dto))
33}
Deleted lib/sagent/src/handlers/tasks/list_tasks.rs lib/sagent/src/handlers/tasks/list_tasks.rs +0 −34
@@ -1,34 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4use session::share::session::require_uid;
5
6use crate::errors::SagentApiError;
7use crate::services::service::SagentService;
8use crate::services::tasks::{AgentPage, CollectionRef, ListAgentQuery};
9
10#[utoipa::path(
11 get,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks",
13 tag = "sagent",
14 description = "分页列出集合内任务,可按关键字、状态、优先级、作者过滤并指定排序。",
15 params(CollectionRef, ListAgentQuery),
16 responses(
17 (status = 200, description = "list tasks", body = AppResponseData<AgentPage>),
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 list_tasks(
24 State(service): State<SagentService>,
25 session: Session,
26 Path(path): Path<CollectionRef>,
27 Query(query): Query<ListAgentQuery>,
28) -> Result<AppResponseData<AgentPage>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let page = service
31 .list_tasks(uid, &path.space_name, path.collection_id, query)
32 .await?;
33 Ok(AppResponseData::ok(page))
34}
Deleted lib/sagent/src/handlers/tasks/mod.rs lib/sagent/src/handlers/tasks/mod.rs +0 −9
@@ -1,9 +0,0 @@
1pub mod cancel_task;
2pub mod close_task;
3pub mod create_task;
4pub mod delete_task;
5pub mod get_task;
6pub mod list_tasks;
7pub mod reopen_task;
8pub mod start_task;
9pub mod update_task;
Deleted lib/sagent/src/handlers/tasks/reopen_task.rs lib/sagent/src/handlers/tasks/reopen_task.rs +0 −34
@@ -1,34 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath};
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/reopen",
13 tag = "sagent",
14 description = "重开已关闭的任务,回到草稿状态。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "reopen task", body = AppResponseData<AgentDto>),
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 (status = 409, description = "task is not closed", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn reopen_task(
25 State(service): State<SagentService>,
26 session: Session,
27 Path(path): Path<AgentPath>,
28) -> Result<AppResponseData<AgentDto>, SagentApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .reopen_task(uid, &path.space_name, path.collection_id, &path.task_id)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Deleted lib/sagent/src/handlers/tasks/start_task.rs lib/sagent/src/handlers/tasks/start_task.rs +0 −38
@@ -1,38 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath};
9
10#[utoipa::path(
11 post,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/start",
13 tag = "sagent",
14 description = "启动任务:创建或复用网关会话、签发会话内调用、下发正文并记录首个 run。",
15 params(AgentPath),
16 responses(
17 (status = 200, description = "start task", body = AppResponseData<AgentDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
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 (status = 409, description = "task already started", body = AppResponseData<NoData>),
23 (status = 429, description = "gateway rate limited", body = AppResponseData<NoData>),
24 (status = 502, description = "gateway rejected the request", body = AppResponseData<NoData>),
25 (status = 503, description = "gateway unavailable", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn start_task(
29 State(service): State<SagentService>,
30 session: Session,
31 Path(path): Path<AgentPath>,
32) -> Result<AppResponseData<AgentDto>, SagentApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .start_task(uid, &path.space_name, path.collection_id, &path.task_id)
36 .await?;
37 Ok(AppResponseData::ok(dto))
38}
Deleted lib/sagent/src/handlers/tasks/update_task.rs lib/sagent/src/handlers/tasks/update_task.rs +0 −43
@@ -1,43 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::{AgentDto, AgentPath, UpdateAgentCmd};
9
10#[utoipa::path(
11 patch,
12 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}",
13 tag = "sagent",
14 description = "更新任务字段;正文仅在草稿状态可改,首次运行后冻结。",
15 params(AgentPath),
16 request_body = UpdateAgentCmd,
17 responses(
18 (status = 200, description = "update task", body = AppResponseData<AgentDto>),
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 (status = 409, description = "task body is frozen", body = AppResponseData<NoData>)
24 )
25)]
26pub async fn update_task(
27 State(service): State<SagentService>,
28 session: Session,
29 Path(path): Path<AgentPath>,
30 Json(cmd): Json<UpdateAgentCmd>,
31) -> Result<AppResponseData<AgentDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .update_task(
35 uid,
36 &path.space_name,
37 path.collection_id,
38 &path.task_id,
39 cmd,
40 )
41 .await?;
42 Ok(AppResponseData::ok(dto))
43}
Deleted lib/sagent/src/handlers/tokens/get_task_token.rs lib/sagent/src/handlers/tokens/get_task_token.rs +0 −38
@@ -1,38 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::AgentPath;
9use crate::services::tokens::TaskTokenDto;
10
11#[utoipa::path(
12 get,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/token",
14 tag = "sagent",
15 description = "为当前用户签发该任务会话的短期令牌,浏览器据此直连沙箱智能体网关订阅事件与发送消息。",
16 params(AgentPath),
17 responses(
18 (status = 200, description = "issue session token", body = AppResponseData<TaskTokenDto>),
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 (status = 409, description = "task has not been started", body = AppResponseData<NoData>),
23 (status = 429, description = "rate limited", body = AppResponseData<NoData>),
24 (status = 502, description = "agent gateway rejected the request", body = AppResponseData<NoData>),
25 (status = 503, description = "agent gateway unavailable", body = AppResponseData<NoData>)
26 )
27)]
28pub async fn get_task_token(
29 State(service): State<SagentService>,
30 session: Session,
31 Path(path): Path<AgentPath>,
32) -> Result<AppResponseData<TaskTokenDto>, SagentApiError> {
33 let uid = require_uid(&session)?;
34 let dto = service
35 .sign_task_token(uid, &path.space_name, path.collection_id, &path.task_id)
36 .await?;
37 Ok(AppResponseData::ok(dto))
38}
Deleted lib/sagent/src/handlers/tokens/mod.rs lib/sagent/src/handlers/tokens/mod.rs +0 −1
@@ -1 +0,0 @@
1pub mod get_task_token;
Deleted lib/sagent/src/handlers/watches/mod.rs lib/sagent/src/handlers/watches/mod.rs +0 −1
@@ -1 +0,0 @@
1pub mod upsert_watch;
Deleted lib/sagent/src/handlers/watches/upsert_watch.rs lib/sagent/src/handlers/watches/upsert_watch.rs +0 −43
@@ -1,43 +0,0 @@
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::service::SagentService;
8use crate::services::tasks::AgentPath;
9use crate::services::watches::{AgentWatchDto, UpsertWatchCmd};
10
11#[utoipa::path(
12 put,
13 path = "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/watch",
14 tag = "sagent",
15 description = "设置当前用户对任务的关注状态。",
16 params(AgentPath),
17 request_body = UpsertWatchCmd,
18 responses(
19 (status = 200, description = "upsert watch", body = AppResponseData<AgentWatchDto>),
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 upsert_watch(
27 State(service): State<SagentService>,
28 session: Session,
29 Path(path): Path<AgentPath>,
30 Json(cmd): Json<UpsertWatchCmd>,
31) -> Result<AppResponseData<AgentWatchDto>, SagentApiError> {
32 let uid = require_uid(&session)?;
33 let dto = service
34 .upsert_watch(
35 uid,
36 &path.space_name,
37 path.collection_id,
38 &path.task_id,
39 cmd,
40 )
41 .await?;
42 Ok(AppResponseData::ok(dto))
43}
Deleted lib/sagent/src/handlers/mod.rs lib/sagent/src/handlers/mod.rs +0 −11
@@ -1,11 +0,0 @@
1pub mod artifacts;
2pub mod collections;
3pub mod events;
4pub mod labels;
5pub mod members;
6pub mod messages;
7pub mod reads;
8pub mod repo_relations;
9pub mod tasks;
10pub mod tokens;
11pub mod watches;
Deleted lib/sagent/src/services/artifacts.rs lib/sagent/src/services/artifacts.rs +0 −153
@@ -1,153 +0,0 @@
1use models::sagent::agent_artifact;
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
4 QuerySelect, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::SagentError;
12use crate::services::service::SagentService;
13use crate::share::lookup::task_context;
14
15const DOWNLOAD_TTL_SECS: u32 = 3600;
16
17#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
18pub struct AgentArtifactDto {
19 pub id: Uuid,
20 pub agent_id: Uuid,
21 pub run_id: Option<Uuid>,
22 pub kind: String,
23 pub name: String,
24 pub mime: Option<String>,
25 pub size: i64,
26 pub storage_key: String,
27 pub created_by: Option<Uuid>,
28 pub created_at: OffsetDateTime,
29 pub download_url: Option<String>,
30}
31
32pub type AgentArtifactPage = export::pagination::Page<AgentArtifactDto>;
33
34#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
35pub struct ListAgentArtifactQuery {
36 pub kind: Option<String>,
37 pub run_id: Option<Uuid>,
38 pub limit: Option<u64>,
39 pub offset: Option<u64>,
40}
41
42#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
43pub struct TaskArtifactPath {
44 pub space_name: String,
45 pub collection_id: Uuid,
46 pub task_id: String,
47 pub artifact_id: Uuid,
48}
49
50impl SagentService {
51 fn artifact_dto(&self, row: agent_artifact::Model) -> AgentArtifactDto {
52 let download_url = self
53 .state
54 .storage
55 .client()
56 .presign_get(&row.storage_key, DOWNLOAD_TTL_SECS)
57 .ok();
58 AgentArtifactDto {
59 id: row.id,
60 agent_id: row.agent_id,
61 run_id: row.run_id,
62 kind: row.kind,
63 name: row.name,
64 mime: row.mime,
65 size: row.size,
66 storage_key: row.storage_key,
67 created_by: row.created_by,
68 created_at: row.created_at,
69 download_url,
70 }
71 }
72
73 pub async fn record_artifact(
74 &self,
75 txn: &impl sea_orm::ConnectionTrait,
76 agent_id: Uuid,
77 run_id: Option<Uuid>,
78 kind: &str,
79 name: &str,
80 mime: Option<String>,
81 size: i64,
82 storage_key: &str,
83 ) -> Result<(), SagentError> {
84 agent_artifact::ActiveModel {
85 id: Set(Uuid::now_v7()),
86 agent_id: Set(agent_id),
87 run_id: Set(run_id),
88 kind: Set(kind.to_string()),
89 name: Set(name.to_string()),
90 mime: Set(mime),
91 size: Set(size),
92 storage_key: Set(storage_key.to_string()),
93 created_by: Set(None),
94 created_at: Set(OffsetDateTime::now_utc()),
95 }
96 .insert(txn)
97 .await?;
98 Ok(())
99 }
100
101 pub async fn list_artifacts(
102 &self,
103 caller: Uuid,
104 space_name: &str,
105 collection_id: Uuid,
106 task_ref: &str,
107 query: ListAgentArtifactQuery,
108 ) -> Result<AgentArtifactPage, SagentError> {
109 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
110 let (limit, offset) = self.paginate(query.limit, query.offset);
111
112 let mut select =
113 agent_artifact::Entity::find().filter(agent_artifact::Column::AgentId.eq(task.id));
114 if let Some(kind) = query.kind {
115 select = select.filter(agent_artifact::Column::Kind.eq(kind));
116 }
117 if let Some(run_id) = query.run_id {
118 select = select.filter(agent_artifact::Column::RunId.eq(run_id));
119 }
120 select = select.order_by_desc(agent_artifact::Column::CreatedAt);
121
122 let total = select.clone().count(self.db().read()).await? as i64;
123 let rows = select
124 .limit(limit)
125 .offset(offset)
126 .all(self.db().read())
127 .await?;
128
129 Ok(AgentArtifactPage {
130 items: rows.into_iter().map(|row| self.artifact_dto(row)).collect(),
131 total,
132 limit,
133 offset,
134 })
135 }
136
137 pub async fn get_artifact(
138 &self,
139 caller: Uuid,
140 space_name: &str,
141 collection_id: Uuid,
142 task_ref: &str,
143 artifact_id: Uuid,
144 ) -> Result<AgentArtifactDto, SagentError> {
145 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
146 let row = agent_artifact::Entity::find_by_id(artifact_id)
147 .filter(agent_artifact::Column::AgentId.eq(task.id))
148 .one(self.db().read())
149 .await?
150 .ok_or(SagentError::NotFound)?;
151 Ok(self.artifact_dto(row))
152 }
153}
Deleted lib/sagent/src/services/collections.rs lib/sagent/src/services/collections.rs +0 −476
@@ -1,476 +0,0 @@
1use std::collections::HashMap;
2
3use models::dir::dirs::{self, DirKind};
4use models::sagent::agent::AgentState;
5use models::sagent::{
6 agent, agent_artifact, agent_event_line, agent_label, agent_member, agent_message,
7 agent_relation_label, agent_relation_repo, agent_user, agent_watch, agents,
8};
9use sea_orm::sea_query::LockType;
10use sea_orm::{
11 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
12 QuerySelect, Set, TransactionTrait, Unchanged,
13};
14use serde::{Deserialize, Serialize};
15use time::OffsetDateTime;
16use utoipa::{IntoParams, ToSchema};
17use uuid::Uuid;
18
19use crate::errors::SagentError;
20use crate::services::service::SagentService;
21use crate::share::authz::require_collection_manage;
22use crate::share::lookup::{lookup_collection, space_context};
23
24pub const ACTIVE_STATES: [AgentState; 4] = [
25 AgentState::Pending,
26 AgentState::Planning,
27 AgentState::Executing,
28 AgentState::Waiting,
29];
30
31#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
32pub struct CollectionDto {
33 pub id: Uuid,
34 pub dir_id: Uuid,
35 pub name: String,
36 pub icon: Option<String>,
37 pub description: Option<String>,
38 pub total_tasks: i64,
39 pub running_tasks: i64,
40 pub created_at: OffsetDateTime,
41 pub updated_at: Option<OffsetDateTime>,
42}
43
44pub type CollectionPage = export::pagination::Page<CollectionDto>;
45
46#[derive(Debug, Clone, Deserialize, ToSchema)]
47pub struct CreateCollectionCmd {
48 pub name: String,
49 #[serde(default, alias = "dir_id")]
50 pub parent_dir_id: Option<Uuid>,
51 #[serde(default)]
52 pub icon: Option<String>,
53 #[serde(default)]
54 pub description: Option<String>,
55}
56
57#[derive(Debug, Clone, Deserialize, ToSchema)]
58pub struct UpdateCollectionCmd {
59 #[serde(default)]
60 pub name: Option<String>,
61 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
62 pub icon: Option<Option<String>>,
63 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
64 pub description: Option<Option<String>>,
65}
66
67#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
68pub struct ListCollectionQuery {
69 pub dir_id: Option<Uuid>,
70 pub q: Option<String>,
71 pub limit: Option<u64>,
72 pub offset: Option<u64>,
73}
74
75#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
76pub struct CollectionPath {
77 pub space_name: String,
78 pub collection_id: Uuid,
79}
80
81impl SagentService {
82 fn collection_dto(
83 collection: agents::Model,
84 total_tasks: i64,
85 running_tasks: i64,
86 ) -> CollectionDto {
87 CollectionDto {
88 id: collection.id,
89 dir_id: collection.dir_id,
90 name: collection.name,
91 icon: collection.icon,
92 description: collection.description,
93 total_tasks,
94 running_tasks,
95 created_at: collection.created_at,
96 updated_at: collection.updated_at,
97 }
98 }
99
100 async fn task_counts_by_collection(
101 &self,
102 collection_ids: &[Uuid],
103 ) -> Result<(HashMap<Uuid, i64>, HashMap<Uuid, i64>), SagentError> {
104 let mut total_counts = HashMap::new();
105 let mut running_counts = HashMap::new();
106 if collection_ids.is_empty() {
107 return Ok((total_counts, running_counts));
108 }
109
110 let total_rows: Vec<(Uuid, i64)> = agent::Entity::find()
111 .select_only()
112 .column(agent::Column::CollectionId)
113 .column_as(agent::Column::CollectionId.count(), "cnt")
114 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
115 .group_by(agent::Column::CollectionId)
116 .into_tuple()
117 .all(self.db().read())
118 .await?;
119 for (collection_id, cnt) in total_rows {
120 total_counts.insert(collection_id, cnt);
121 }
122
123 let running_rows: Vec<(Uuid, i64)> = agent::Entity::find()
124 .select_only()
125 .column(agent::Column::CollectionId)
126 .column_as(agent::Column::CollectionId.count(), "cnt")
127 .filter(agent::Column::CollectionId.is_in(collection_ids.to_vec()))
128 .filter(agent::Column::State.is_in(ACTIVE_STATES))
129 .group_by(agent::Column::CollectionId)
130 .into_tuple()
131 .all(self.db().read())
132 .await?;
133 for (collection_id, cnt) in running_rows {
134 running_counts.insert(collection_id, cnt);
135 }
136
137 Ok((total_counts, running_counts))
138 }
139
140 async fn build_collection_dto(
141 &self,
142 collection: agents::Model,
143 ) -> Result<CollectionDto, SagentError> {
144 let (total_counts, running_counts) =
145 self.task_counts_by_collection(&[collection.id]).await?;
146 let total = total_counts.get(&collection.id).copied().unwrap_or(0);
147 let running = running_counts.get(&collection.id).copied().unwrap_or(0);
148 Ok(Self::collection_dto(collection, total, running))
149 }
150
151 pub async fn create_collection(
152 &self,
153 caller: Uuid,
154 space_name: &str,
155 cmd: CreateCollectionCmd,
156 ) -> Result<CollectionDto, SagentError> {
157 let space = space_context(self, caller, space_name).await?;
158 require_collection_manage(self, &space, caller).await?;
159
160 let name = cmd.name.trim();
161 if name.is_empty() || name.chars().count() > self.config().sagent.max_collection_name_len {
162 return Err(SagentError::BadRequest(
163 "collection name is empty or too long".to_string(),
164 ));
165 }
166
167 if let Some(parent_id) = cmd.parent_dir_id {
168 let parent = dirs::Entity::find_by_id(parent_id)
169 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
170 .one(self.db().read())
171 .await?
172 .ok_or(SagentError::NotFound)?;
173 if parent.kind != DirKind::Dir {
174 return Err(SagentError::BadRequest(
175 "parent node must be a dir".to_string(),
176 ));
177 }
178 }
179
180 let collection = {
181 let txn = self.db().write().begin().await?;
182 let max_node = dirs::Entity::find()
183 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
184 .filter(dirs::Column::ParentDirId.eq(cmd.parent_dir_id))
185 .order_by_desc(dirs::Column::Position)
186 .lock(LockType::Update)
187 .one(&txn)
188 .await?;
189 let position = max_node.map(|node| node.position + 1).unwrap_or(0);
190 let now = OffsetDateTime::now_utc();
191
192 let dir = dirs::ActiveModel {
193 id: Set(Uuid::now_v7()),
194 space_id: Set(Some(space.id)),
195 name: Set(name.to_string()),
196 kind: Set(DirKind::Object),
197 parent_dir_id: Set(cmd.parent_dir_id),
198 color: Set(None),
199 position: Set(position),
200 icon: Set(cmd.icon.clone()),
201 topic: Set(None),
202 purpose: Set(cmd.description.clone()),
203 is_archived: Set(false),
204 archived_at: Set(None),
205 archived_by: Set(None),
206 created_at: Set(now),
207 updated_at: Set(now),
208 created_by_uid: Set(caller),
209 }
210 .insert(&txn)
211 .await?;
212
213 let row = agents::ActiveModel {
214 id: Set(Uuid::now_v7()),
215 dir_id: Set(dir.id),
216 name: Set(name.to_string()),
217 icon: Set(cmd.icon),
218 description: Set(cmd.description),
219 config: Set(None),
220 created_at: Set(now),
221 updated_at: Set(None),
222 }
223 .insert(&txn)
224 .await?;
225
226 txn.commit().await?;
227 row
228 };
229
230 Ok(Self::collection_dto(collection, 0, 0))
231 }
232
233 pub async fn list_collections(
234 &self,
235 caller: Uuid,
236 space_name: &str,
237 query: ListCollectionQuery,
238 ) -> Result<CollectionPage, SagentError> {
239 let space = space_context(self, caller, space_name).await?;
240 let (limit, offset) = self.paginate(query.limit, query.offset);
241
242 let dirs_in_space = dirs::Entity::find()
243 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
244 .filter(dirs::Column::Kind.eq(DirKind::Object))
245 .all(self.db().read())
246 .await?;
247
248 if dirs_in_space.is_empty() {
249 return Ok(CollectionPage {
250 items: Vec::new(),
251 total: 0,
252 limit,
253 offset,
254 });
255 }
256
257 let dir_ids: Vec<Uuid> = if let Some(target_dir) = query.dir_id {
258 dirs_in_space
259 .into_iter()
260 .filter(|dir| dir.parent_dir_id == Some(target_dir) || dir.id == target_dir)
261 .map(|dir| dir.id)
262 .collect()
263 } else {
264 dirs_in_space.into_iter().map(|dir| dir.id).collect()
265 };
266
267 if dir_ids.is_empty() {
268 return Ok(CollectionPage {
269 items: Vec::new(),
270 total: 0,
271 limit,
272 offset,
273 });
274 }
275
276 let mut select = agents::Entity::find().filter(agents::Column::DirId.is_in(dir_ids));
277 if let Some(search) = query.q {
278 let keyword = search.trim();
279 if !keyword.is_empty() {
280 select = select.filter(agents::Column::Name.contains(keyword));
281 }
282 }
283 select = select.order_by_desc(agents::Column::CreatedAt);
284
285 let total = select.clone().count(self.db().read()).await? as i64;
286 let rows = select
287 .limit(limit)
288 .offset(offset)
289 .all(self.db().read())
290 .await?;
291
292 let ids: Vec<Uuid> = rows.iter().map(|row| row.id).collect();
293 let (total_counts, running_counts) = self.task_counts_by_collection(&ids).await?;
294 let items = rows
295 .into_iter()
296 .map(|row| {
297 let total = total_counts.get(&row.id).copied().unwrap_or(0);
298 let running = running_counts.get(&row.id).copied().unwrap_or(0);
299 Self::collection_dto(row, total, running)
300 })
301 .collect();
302
303 Ok(CollectionPage {
304 items,
305 total,
306 limit,
307 offset,
308 })
309 }
310
311 pub async fn get_collection(
312 &self,
313 caller: Uuid,
314 space_name: &str,
315 collection_id: Uuid,
316 ) -> Result<CollectionDto, SagentError> {
317 let space = space_context(self, caller, space_name).await?;
318 let collection = lookup_collection(self, &space, collection_id, caller).await?;
319 self.build_collection_dto(collection).await
320 }
321
322 pub async fn update_collection(
323 &self,
324 caller: Uuid,
325 space_name: &str,
326 collection_id: Uuid,
327 cmd: UpdateCollectionCmd,
328 ) -> Result<CollectionDto, SagentError> {
329 let space = space_context(self, caller, space_name).await?;
330 require_collection_manage(self, &space, caller).await?;
331 let collection = lookup_collection(self, &space, collection_id, caller).await?;
332
333 let now = OffsetDateTime::now_utc();
334 let mut collection_am: agents::ActiveModel = collection.clone().into();
335 let mut dir_am = dirs::ActiveModel {
336 id: Unchanged(collection.dir_id),
337 ..Default::default()
338 };
339 let mut dir_changed = false;
340
341 if let Some(new_name) = cmd.name {
342 let name = new_name.trim();
343 if name.is_empty()
344 || name.chars().count() > self.config().sagent.max_collection_name_len
345 {
346 return Err(SagentError::BadRequest(
347 "collection name is empty or too long".to_string(),
348 ));
349 }
350 collection_am.name = Set(name.to_string());
351 dir_am.name = Set(name.to_string());
352 dir_changed = true;
353 }
354 if let Some(icon) = cmd.icon {
355 collection_am.icon = Set(icon.clone());
356 dir_am.icon = Set(icon);
357 dir_changed = true;
358 }
359 if let Some(description) = cmd.description {
360 collection_am.description = Set(description.clone());
361 dir_am.purpose = Set(description);
362 dir_changed = true;
363 }
364
365 collection_am.updated_at = Set(Some(now));
366
367 let updated = {
368 let txn = self.db().write().begin().await?;
369 if dir_changed {
370 dir_am.updated_at = Set(now);
371 dir_am.update(&txn).await?;
372 }
373 let updated = collection_am.update(&txn).await?;
374 txn.commit().await?;
375 updated
376 };
377
378 self.build_collection_dto(updated).await
379 }
380
381 pub async fn delete_collection(
382 &self,
383 caller: Uuid,
384 space_name: &str,
385 collection_id: Uuid,
386 ) -> Result<(), SagentError> {
387 let space = space_context(self, caller, space_name).await?;
388 require_collection_manage(self, &space, caller).await?;
389 let collection = lookup_collection(self, &space, collection_id, caller).await?;
390
391 self.db()
392 .primary()
393 .transaction::<_, (), SagentError>(|txn| {
394 Box::pin(async move {
395 let task_ids: Vec<Uuid> = agent::Entity::find()
396 .filter(agent::Column::CollectionId.eq(collection.id))
397 .all(txn)
398 .await?
399 .into_iter()
400 .map(|row| row.id)
401 .collect();
402
403 if !task_ids.is_empty() {
404 agent_artifact::Entity::delete_many()
405 .filter(agent_artifact::Column::AgentId.is_in(task_ids.clone()))
406 .exec(txn)
407 .await?;
408 agent_relation_repo::Entity::delete_many()
409 .filter(agent_relation_repo::Column::AgentId.is_in(task_ids.clone()))
410 .exec(txn)
411 .await?;
412 agent_relation_label::Entity::delete_many()
413 .filter(agent_relation_label::Column::AgentId.is_in(task_ids.clone()))
414 .exec(txn)
415 .await?;
416 agent_user::Entity::delete_many()
417 .filter(agent_user::Column::AgentId.is_in(task_ids.clone()))
418 .exec(txn)
419 .await?;
420 agent_member::Entity::delete_many()
421 .filter(agent_member::Column::AgentId.is_in(task_ids.clone()))
422 .exec(txn)
423 .await?;
424 agent_message::Entity::delete_many()
425 .filter(agent_message::Column::AgentId.is_in(task_ids.clone()))
426 .exec(txn)
427 .await?;
428 agent_watch::Entity::delete_many()
429 .filter(agent_watch::Column::AgentId.is_in(task_ids.clone()))
430 .exec(txn)
431 .await?;
432 agent_event_line::Entity::delete_many()
433 .filter(agent_event_line::Column::AgentId.is_in(task_ids))
434 .exec(txn)
435 .await?;
436 }
437
438 agent_event_line::Entity::delete_many()
439 .filter(agent_event_line::Column::CollectionId.eq(collection.id))
440 .exec(txn)
441 .await?;
442 agent::Entity::delete_many()
443 .filter(agent::Column::CollectionId.eq(collection.id))
444 .exec(txn)
445 .await?;
446 let label_ids: Vec<Uuid> = agent_label::Entity::find()
447 .filter(agent_label::Column::DirId.eq(collection.dir_id))
448 .all(txn)
449 .await?
450 .into_iter()
451 .map(|row| row.id)
452 .collect();
453 if !label_ids.is_empty() {
454 agent_relation_label::Entity::delete_many()
455 .filter(agent_relation_label::Column::LabelId.is_in(label_ids))
456 .exec(txn)
457 .await?;
458 }
459 agent_label::Entity::delete_many()
460 .filter(agent_label::Column::DirId.eq(collection.dir_id))
461 .exec(txn)
462 .await?;
463 agents::Entity::delete_by_id(collection.id)
464 .exec(txn)
465 .await?;
466 dirs::Entity::delete_by_id(collection.dir_id)
467 .exec(txn)
468 .await?;
469 Ok(())
470 })
471 })
472 .await?;
473
474 Ok(())
475 }
476}
Deleted lib/sagent/src/services/events.rs lib/sagent/src/services/events.rs +0 −75
@@ -1,75 +0,0 @@
1use models::sagent::agent_event_line::{self, AgentEventType};
2use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::SagentError;
9use crate::services::service::SagentService;
10use crate::share::lookup::task_context;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct AgentEventDto {
14 pub id: Uuid,
15 pub agent_id: Option<Uuid>,
16 pub collection_id: Option<Uuid>,
17 pub user_id: Option<Uuid>,
18 pub event_type: AgentEventType,
19 pub run_id: Option<Uuid>,
20 pub content: Option<serde_json::Value>,
21 pub created_at: OffsetDateTime,
22}
23
24pub type AgentEventPage = export::pagination::Page<AgentEventDto>;
25
26#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
27pub struct ListAgentEventQuery {
28 pub limit: Option<u64>,
29 pub offset: Option<u64>,
30}
31
32impl SagentService {
33 pub(crate) fn event_dto(event: agent_event_line::Model) -> AgentEventDto {
34 AgentEventDto {
35 id: event.id,
36 agent_id: event.agent_id,
37 collection_id: event.collection_id,
38 user_id: event.user_id,
39 event_type: event.event_type,
40 run_id: event.run_id,
41 content: event.content,
42 created_at: event.created_at,
43 }
44 }
45
46 pub async fn list_events(
47 &self,
48 caller: Uuid,
49 space_name: &str,
50 collection_id: Uuid,
51 task_ref: &str,
52 query: ListAgentEventQuery,
53 ) -> Result<AgentEventPage, SagentError> {
54 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
55 let (limit, offset) = self.paginate(query.limit, query.offset);
56
57 let select = agent_event_line::Entity::find()
58 .filter(agent_event_line::Column::AgentId.eq(task.id))
59 .order_by_desc(agent_event_line::Column::CreatedAt);
60
61 let total = select.clone().count(self.db().read()).await? as i64;
62 let rows = select
63 .limit(limit)
64 .offset(offset)
65 .all(self.db().read())
66 .await?;
67
68 Ok(AgentEventPage {
69 items: rows.into_iter().map(Self::event_dto).collect(),
70 total,
71 limit,
72 offset,
73 })
74 }
75}
Deleted lib/sagent/src/services/labels.rs lib/sagent/src/services/labels.rs +0 −417
@@ -1,417 +0,0 @@
1use models::sagent::agent_event_line::AgentEventType;
2use models::sagent::{agent_label, agent_relation_label};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set, TransactionTrait,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::SagentError;
13use crate::services::service::SagentService;
14use crate::share::authz::require_collection_manage;
15use crate::share::lookup::{collection_context, task_context};
16
17#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
18pub struct AgentLabelDto {
19 pub id: Uuid,
20 pub dir_id: Uuid,
21 pub name: String,
22 pub exclusive: bool,
23 pub exclusive_order: i32,
24 pub description: Option<String>,
25 pub color: String,
26 pub is_archived: bool,
27 pub created_at: OffsetDateTime,
28 pub updated_at: Option<OffsetDateTime>,
29}
30
31impl From<agent_label::Model> for AgentLabelDto {
32 fn from(row: agent_label::Model) -> Self {
33 Self {
34 id: row.id,
35 dir_id: row.dir_id,
36 name: row.name,
37 exclusive: row.exclusive,
38 exclusive_order: row.exclusive_order,
39 description: row.description,
40 color: row.color,
41 is_archived: row.is_archived,
42 created_at: row.created_at,
43 updated_at: row.updated_at,
44 }
45 }
46}
47
48pub type AgentLabelPage = export::pagination::Page<AgentLabelDto>;
49
50#[derive(Debug, Clone, Deserialize, ToSchema)]
51pub struct CreateAgentLabelCmd {
52 pub name: String,
53 pub color: String,
54 #[serde(default)]
55 pub exclusive: Option<bool>,
56 #[serde(default)]
57 pub exclusive_order: Option<i32>,
58 #[serde(default)]
59 pub description: Option<String>,
60}
61
62#[derive(Debug, Clone, Deserialize, ToSchema)]
63pub struct UpdateAgentLabelCmd {
64 #[serde(default)]
65 pub name: Option<String>,
66 #[serde(default)]
67 pub color: Option<String>,
68 #[serde(default)]
69 pub exclusive: Option<bool>,
70 #[serde(default)]
71 pub exclusive_order: Option<i32>,
72 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
73 pub description: Option<Option<String>>,
74 #[serde(default)]
75 pub is_archived: Option<bool>,
76}
77
78#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
79pub struct ListAgentLabelQuery {
80 pub q: Option<String>,
81 pub is_archived: Option<bool>,
82 pub limit: Option<u64>,
83 pub offset: Option<u64>,
84}
85
86#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
87pub struct AgentLabelPath {
88 pub space_name: String,
89 pub collection_id: Uuid,
90 pub label_id: Uuid,
91}
92
93#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
94pub struct TaskLabelPath {
95 pub space_name: String,
96 pub collection_id: Uuid,
97 pub task_id: String,
98 pub label_id: Uuid,
99}
100
101fn validate_hex_color(color: &str) -> bool {
102 let clean = color.trim();
103 clean.len() == 7 && clean.starts_with('#') && clean[1..].chars().all(|c| c.is_ascii_hexdigit())
104}
105
106impl SagentService {
107 fn check_label_name(&self, name: &str) -> Result<String, SagentError> {
108 let trimmed = name.trim();
109 if trimmed.is_empty() || trimmed.chars().count() > self.config().sagent.max_label_name_len {
110 return Err(SagentError::BadRequest(
111 "label name is empty or too long".to_string(),
112 ));
113 }
114 Ok(trimmed.to_string())
115 }
116
117 fn check_label_color(&self, color: &str) -> Result<String, SagentError> {
118 let trimmed = color.trim();
119 if !validate_hex_color(trimmed) {
120 return Err(SagentError::BadRequest(
121 "invalid color format: must be #rrggbb hex code".to_string(),
122 ));
123 }
124 Ok(trimmed.to_string())
125 }
126
127 async fn lookup_label(
128 &self,
129 dir_id: Uuid,
130 label_id: Uuid,
131 ) -> Result<agent_label::Model, SagentError> {
132 agent_label::Entity::find_by_id(label_id)
133 .filter(agent_label::Column::DirId.eq(dir_id))
134 .one(self.db().read())
135 .await?
136 .ok_or(SagentError::NotFound)
137 }
138
139 pub async fn create_label(
140 &self,
141 caller: Uuid,
142 space_name: &str,
143 collection_id: Uuid,
144 cmd: CreateAgentLabelCmd,
145 ) -> Result<AgentLabelDto, SagentError> {
146 let (space, collection) =
147 collection_context(self, caller, space_name, collection_id).await?;
148 require_collection_manage(self, &space, caller).await?;
149 let name = self.check_label_name(&cmd.name)?;
150 let color = self.check_label_color(&cmd.color)?;
151 if cmd.exclusive_order.is_some_and(|order| order < 0) {
152 return Err(SagentError::BadRequest(
153 "exclusive_order must be non-negative".to_string(),
154 ));
155 }
156
157 let row = agent_label::ActiveModel {
158 id: Set(Uuid::now_v7()),
159 dir_id: Set(collection.dir_id),
160 name: Set(name),
161 exclusive: Set(cmd.exclusive.unwrap_or(false)),
162 exclusive_order: Set(cmd.exclusive_order.unwrap_or(0)),
163 description: Set(cmd.description),
164 color: Set(color),
165 is_archived: Set(false),
166 created_at: Set(OffsetDateTime::now_utc()),
167 updated_at: Set(None),
168 }
169 .insert(self.db().write())
170 .await?;
171
172 Ok(AgentLabelDto::from(row))
173 }
174
175 pub async fn list_labels(
176 &self,
177 caller: Uuid,
178 space_name: &str,
179 collection_id: Uuid,
180 query: ListAgentLabelQuery,
181 ) -> Result<AgentLabelPage, SagentError> {
182 let (_, collection) = collection_context(self, caller, space_name, collection_id).await?;
183 let (limit, offset) = self.paginate(query.limit, query.offset);
184
185 let mut select =
186 agent_label::Entity::find().filter(agent_label::Column::DirId.eq(collection.dir_id));
187 if let Some(is_archived) = query.is_archived {
188 select = select.filter(agent_label::Column::IsArchived.eq(is_archived));
189 }
190 if let Some(search) = query.q {
191 let keyword = search.trim();
192 if !keyword.is_empty() {
193 select = select.filter(agent_label::Column::Name.contains(keyword));
194 }
195 }
196 select = select.order_by_desc(agent_label::Column::CreatedAt);
197
198 let total = select.clone().count(self.db().read()).await? as i64;
199 let rows = select
200 .limit(limit)
201 .offset(offset)
202 .all(self.db().read())
203 .await?;
204
205 Ok(AgentLabelPage {
206 items: rows.into_iter().map(AgentLabelDto::from).collect(),
207 total,
208 limit,
209 offset,
210 })
211 }
212
213 pub async fn get_label(
214 &self,
215 caller: Uuid,
216 space_name: &str,
217 collection_id: Uuid,
218 label_id: Uuid,
219 ) -> Result<AgentLabelDto, SagentError> {
220 let (_, collection) = collection_context(self, caller, space_name, collection_id).await?;
221 let row = self.lookup_label(collection.dir_id, label_id).await?;
222 Ok(AgentLabelDto::from(row))
223 }
224
225 pub async fn update_label(
226 &self,
227 caller: Uuid,
228 space_name: &str,
229 collection_id: Uuid,
230 label_id: Uuid,
231 cmd: UpdateAgentLabelCmd,
232 ) -> Result<AgentLabelDto, SagentError> {
233 let (space, collection) =
234 collection_context(self, caller, space_name, collection_id).await?;
235 require_collection_manage(self, &space, caller).await?;
236 let row = self.lookup_label(collection.dir_id, label_id).await?;
237
238 let mut update: agent_label::ActiveModel = row.into();
239 if let Some(name) = cmd.name {
240 update.name = Set(self.check_label_name(&name)?);
241 }
242 if let Some(color) = cmd.color {
243 update.color = Set(self.check_label_color(&color)?);
244 }
245 if let Some(exclusive) = cmd.exclusive {
246 update.exclusive = Set(exclusive);
247 }
248 if let Some(order) = cmd.exclusive_order {
249 if order < 0 {
250 return Err(SagentError::BadRequest(
251 "exclusive_order must be non-negative".to_string(),
252 ));
253 }
254 update.exclusive_order = Set(order);
255 }
256 if let Some(description) = cmd.description {
257 update.description = Set(description);
258 }
259 if let Some(is_archived) = cmd.is_archived {
260 update.is_archived = Set(is_archived);
261 }
262 update.updated_at = Set(Some(OffsetDateTime::now_utc()));
263
264 let updated = update.update(self.db().write()).await?;
265 Ok(AgentLabelDto::from(updated))
266 }
267
268 pub async fn delete_label(
269 &self,
270 caller: Uuid,
271 space_name: &str,
272 collection_id: Uuid,
273 label_id: Uuid,
274 ) -> Result<(), SagentError> {
275 let (space, collection) =
276 collection_context(self, caller, space_name, collection_id).await?;
277 require_collection_manage(self, &space, caller).await?;
278 let _ = self.lookup_label(collection.dir_id, label_id).await?;
279
280 self.db()
281 .primary()
282 .transaction::<_, (), SagentError>(|txn| {
283 Box::pin(async move {
284 agent_relation_label::Entity::delete_many()
285 .filter(agent_relation_label::Column::LabelId.eq(label_id))
286 .exec(txn)
287 .await?;
288 agent_label::Entity::delete_by_id(label_id)
289 .exec(txn)
290 .await?;
291 Ok(())
292 })
293 })
294 .await?;
295
296 Ok(())
297 }
298
299 pub async fn list_task_labels(
300 &self,
301 caller: Uuid,
302 space_name: &str,
303 collection_id: Uuid,
304 task_ref: &str,
305 ) -> Result<Vec<AgentLabelDto>, SagentError> {
306 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
307 self.task_labels(task.id).await
308 }
309
310 pub(crate) async fn task_labels(
311 &self,
312 task_id: Uuid,
313 ) -> Result<Vec<AgentLabelDto>, SagentError> {
314 let label_ids: Vec<Uuid> = agent_relation_label::Entity::find()
315 .filter(agent_relation_label::Column::AgentId.eq(task_id))
316 .all(self.db().read())
317 .await?
318 .into_iter()
319 .map(|row| row.label_id)
320 .collect();
321 if label_ids.is_empty() {
322 return Ok(Vec::new());
323 }
324 let rows = agent_label::Entity::find()
325 .filter(agent_label::Column::Id.is_in(label_ids))
326 .all(self.db().read())
327 .await?;
328 Ok(rows.into_iter().map(AgentLabelDto::from).collect())
329 }
330
331 pub async fn bind_label(
332 &self,
333 caller: Uuid,
334 space_name: &str,
335 collection_id: Uuid,
336 task_ref: &str,
337 label_id: Uuid,
338 ) -> Result<(), SagentError> {
339 let (space, collection, task) =
340 task_context(self, caller, space_name, collection_id, task_ref).await?;
341 crate::share::authz::require_task_write(self, &space, caller, &task).await?;
342 let label = self.lookup_label(collection.dir_id, label_id).await?;
343
344 let existing = agent_relation_label::Entity::find()
345 .filter(agent_relation_label::Column::AgentId.eq(task.id))
346 .filter(agent_relation_label::Column::LabelId.eq(label_id))
347 .one(self.db().read())
348 .await?;
349 if existing.is_some() {
350 return Err(SagentError::Conflict(
351 "label already bound to task".to_string(),
352 ));
353 }
354
355 let txn = self.db().write().begin().await?;
356 agent_relation_label::ActiveModel {
357 id: Set(Uuid::now_v7()),
358 agent_id: Set(task.id),
359 label_id: Set(label_id),
360 created_at: Set(OffsetDateTime::now_utc()),
361 }
362 .insert(&txn)
363 .await?;
364 self.write_event(
365 &txn,
366 task.id,
367 collection.id,
368 Some(caller),
369 AgentEventType::LabelAdded,
370 None,
371 Some(serde_json::json!({ "label": label.name })),
372 )
373 .await?;
374 txn.commit().await?;
375
376 Ok(())
377 }
378
379 pub async fn unbind_label(
380 &self,
381 caller: Uuid,
382 space_name: &str,
383 collection_id: Uuid,
384 task_ref: &str,
385 label_id: Uuid,
386 ) -> Result<(), SagentError> {
387 let (space, collection, task) =
388 task_context(self, caller, space_name, collection_id, task_ref).await?;
389 crate::share::authz::require_task_write(self, &space, caller, &task).await?;
390 let label = self.lookup_label(collection.dir_id, label_id).await?;
391
392 let relation = agent_relation_label::Entity::find()
393 .filter(agent_relation_label::Column::AgentId.eq(task.id))
394 .filter(agent_relation_label::Column::LabelId.eq(label_id))
395 .one(self.db().read())
396 .await?
397 .ok_or(SagentError::NotFound)?;
398
399 let txn = self.db().write().begin().await?;
400 agent_relation_label::Entity::delete_by_id(relation.id)
401 .exec(&txn)
402 .await?;
403 self.write_event(
404 &txn,
405 task.id,
406 collection.id,
407 Some(caller),
408 AgentEventType::LabelRemoved,
409 None,
410 Some(serde_json::json!({ "label": label.name })),
411 )
412 .await?;
413 txn.commit().await?;
414
415 Ok(())
416 }
417}
Deleted lib/sagent/src/services/members.rs lib/sagent/src/services/members.rs +0 −265
@@ -1,265 +0,0 @@
1use models::sagent::agent_event_line::AgentEventType;
2use models::sagent::agent_member::{self, AgentMemberKind};
3use models::space::space_members::{self, MemberStatus};
4use models::users::user_notifications::NotificationType;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
7 QuerySelect, Set, TransactionTrait,
8};
9use serde::{Deserialize, Serialize};
10use space::services::spaces::UserDto;
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::errors::SagentError;
16use crate::services::service::SagentService;
17use crate::share::authz::require_task_write;
18use crate::share::lookup::task_context;
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21pub struct AgentMemberDto {
22 pub id: Uuid,
23 pub agent_id: Uuid,
24 pub user: Option<UserDto>,
25 pub kind: AgentMemberKind,
26 pub created_at: OffsetDateTime,
27 pub updated_at: Option<OffsetDateTime>,
28}
29
30pub type AgentMemberPage = export::pagination::Page<AgentMemberDto>;
31
32#[derive(Debug, Clone, Deserialize, ToSchema)]
33pub struct CreateAgentMemberCmd {
34 pub user_id: Uuid,
35 #[serde(default)]
36 pub kind: Option<AgentMemberKind>,
37}
38
39#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
40pub struct ListAgentMemberQuery {
41 #[param(value_type = String, required = false)]
42 pub kind: Option<AgentMemberKind>,
43 pub limit: Option<u64>,
44 pub offset: Option<u64>,
45}
46
47#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
48pub struct TaskMemberPath {
49 pub space_name: String,
50 pub collection_id: Uuid,
51 pub task_id: String,
52 pub user_id: Uuid,
53}
54
55impl SagentService {
56 async fn require_space_member(
57 &self,
58 space_id: Uuid,
59 owner_id: Uuid,
60 user_id: Uuid,
61 ) -> Result<(), SagentError> {
62 if user_id == owner_id {
63 return Ok(());
64 }
65 let member = space_members::Entity::find_by_id((space_id, user_id))
66 .one(self.db().read())
67 .await?;
68 match member {
69 Some(row) if row.status == MemberStatus::Active => Ok(()),
70 _ => Err(SagentError::Forbidden),
71 }
72 }
73
74 pub async fn create_member(
75 &self,
76 caller: Uuid,
77 space_name: &str,
78 collection_id: Uuid,
79 task_ref: &str,
80 cmd: CreateAgentMemberCmd,
81 ) -> Result<AgentMemberDto, SagentError> {
82 let (space, collection, task) =
83 task_context(self, caller, space_name, collection_id, task_ref).await?;
84 require_task_write(self, &space, caller, &task).await?;
85 self.require_space_member(space.id, space.owner_id, cmd.user_id)
86 .await?;
87 let kind = cmd.kind.unwrap_or(AgentMemberKind::Assignee);
88
89 let existing = agent_member::Entity::find()
90 .filter(agent_member::Column::AgentId.eq(task.id))
91 .filter(agent_member::Column::UserId.eq(cmd.user_id))
92 .filter(agent_member::Column::Kind.eq(kind))
93 .one(self.db().read())
94 .await?;
95 if existing.is_some() {
96 return Err(SagentError::Conflict(
97 "member already exists with this role".to_string(),
98 ));
99 }
100
101 let now = OffsetDateTime::now_utc();
102 let member_id = Uuid::now_v7();
103 let target_uid = cmd.user_id;
104 let task_title = task.title.clone();
105 let task_number = task.autoincrement_id;
106 let collection_id_for_txn = collection.id;
107 let this = self.clone();
108 let space_lower = space.lower_name.clone();
109 let pending = export::notify::PendingNotifications::default();
110 let pending_txn = pending.clone();
111
112 let inserted = self
113 .db()
114 .primary()
115 .transaction::<_, agent_member::Model, SagentError>(move |txn| {
116 let title = task_title.clone();
117 let space_lower = space_lower.clone();
118 Box::pin(async move {
119 let row = agent_member::ActiveModel {
120 id: Set(member_id),
121 agent_id: Set(task.id),
122 user_id: Set(target_uid),
123 kind: Set(kind),
124 created_at: Set(now),
125 updated_at: Set(None),
126 };
127 let inserted = row.insert(txn).await?;
128
129 this.write_event(
130 txn,
131 task.id,
132 collection_id_for_txn,
133 Some(caller),
134 AgentEventType::Assigned,
135 None,
136 Some(serde_json::json!({ "user_id": target_uid, "kind": kind })),
137 )
138 .await?;
139
140 if kind == AgentMemberKind::Assignee && target_uid != caller {
141 let data = serde_json::json!({
142 "space_name": space_lower,
143 "collection_id": collection_id_for_txn,
144 "agent_id": task.id,
145 "number": task_number,
146 });
147 this.notify_user(
148 txn,
149 &pending_txn,
150 target_uid,
151 NotificationType::Assignment,
152 format!("You were assigned to agent task #{task_number}: {title}"),
153 Some(title),
154 Some(data),
155 Some(caller),
156 )
157 .await?;
158 }
159
160 Ok(inserted)
161 })
162 })
163 .await?;
164 pending.flush(&self.state.redis).await;
165
166 let briefs = self.load_user_briefs(&[cmd.user_id]).await?;
167 Ok(AgentMemberDto {
168 id: inserted.id,
169 agent_id: inserted.agent_id,
170 user: briefs.get(&cmd.user_id).cloned(),
171 kind: inserted.kind,
172 created_at: inserted.created_at,
173 updated_at: inserted.updated_at,
174 })
175 }
176
177 pub async fn list_members(
178 &self,
179 caller: Uuid,
180 space_name: &str,
181 collection_id: Uuid,
182 task_ref: &str,
183 query: ListAgentMemberQuery,
184 ) -> Result<AgentMemberPage, SagentError> {
185 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
186 let (limit, offset) = self.paginate(query.limit, query.offset);
187
188 let mut select =
189 agent_member::Entity::find().filter(agent_member::Column::AgentId.eq(task.id));
190 if let Some(kind) = query.kind {
191 select = select.filter(agent_member::Column::Kind.eq(kind));
192 }
193 select = select.order_by_asc(agent_member::Column::CreatedAt);
194
195 let total = select.clone().count(self.db().read()).await? as i64;
196 let rows = select
197 .limit(limit)
198 .offset(offset)
199 .all(self.db().read())
200 .await?;
201
202 let uids: Vec<Uuid> = rows.iter().map(|row| row.user_id).collect();
203 let briefs = self.load_user_briefs(&uids).await?;
204 let items = rows
205 .into_iter()
206 .map(|row| AgentMemberDto {
207 id: row.id,
208 agent_id: row.agent_id,
209 user: briefs.get(&row.user_id).cloned(),
210 kind: row.kind,
211 created_at: row.created_at,
212 updated_at: row.updated_at,
213 })
214 .collect();
215
216 Ok(AgentMemberPage {
217 items,
218 total,
219 limit,
220 offset,
221 })
222 }
223
224 pub async fn delete_member(
225 &self,
226 caller: Uuid,
227 space_name: &str,
228 collection_id: Uuid,
229 task_ref: &str,
230 user_id: Uuid,
231 ) -> Result<(), SagentError> {
232 let (space, collection, task) =
233 task_context(self, caller, space_name, collection_id, task_ref).await?;
234 require_task_write(self, &space, caller, &task).await?;
235
236 let rows = agent_member::Entity::find()
237 .filter(agent_member::Column::AgentId.eq(task.id))
238 .filter(agent_member::Column::UserId.eq(user_id))
239 .all(self.db().read())
240 .await?;
241 if rows.is_empty() {
242 return Err(SagentError::NotFound);
243 }
244
245 let txn = self.db().write().begin().await?;
246 for row in rows {
247 agent_member::Entity::delete_by_id(row.id)
248 .exec(&txn)
249 .await?;
250 self.write_event(
251 &txn,
252 task.id,
253 collection.id,
254 Some(caller),
255 AgentEventType::Unassigned,
256 None,
257 Some(serde_json::json!({ "user_id": user_id, "kind": row.kind })),
258 )
259 .await?;
260 }
261 txn.commit().await?;
262
263 Ok(())
264 }
265}
Deleted lib/sagent/src/services/messages.rs lib/sagent/src/services/messages.rs +0 −79
@@ -1,79 +0,0 @@
1use models::sagent::agent_message::{self, AgentRole};
2use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
7
8use crate::errors::SagentError;
9use crate::services::service::SagentService;
10use crate::share::lookup::task_context;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct AgentMessageDto {
14 pub id: Uuid,
15 pub agent_id: Uuid,
16 pub seq: i64,
17 pub gateway_message_id: Option<Uuid>,
18 pub author_id: Option<Uuid>,
19 pub role: AgentRole,
20 pub content: String,
21 pub created_at: OffsetDateTime,
22}
23
24pub type AgentMessagePage = export::pagination::Page<AgentMessageDto>;
25
26#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
27pub struct ListMessageQuery {
28 pub after_seq: Option<i64>,
29 pub limit: Option<u64>,
30 pub offset: Option<u64>,
31}
32
33impl SagentService {
34 pub(crate) fn message_dto(message: agent_message::Model) -> AgentMessageDto {
35 AgentMessageDto {
36 id: message.id,
37 agent_id: message.agent_id,
38 seq: message.seq,
39 gateway_message_id: message.gateway_message_id,
40 author_id: message.author_id,
41 role: message.role,
42 content: message.content,
43 created_at: message.created_at,
44 }
45 }
46
47 pub async fn list_messages(
48 &self,
49 caller: Uuid,
50 space_name: &str,
51 collection_id: Uuid,
52 task_ref: &str,
53 query: ListMessageQuery,
54 ) -> Result<AgentMessagePage, SagentError> {
55 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
56 let (limit, offset) = self.paginate(query.limit, query.offset);
57
58 let mut select =
59 agent_message::Entity::find().filter(agent_message::Column::AgentId.eq(task.id));
60 if let Some(after_seq) = query.after_seq {
61 select = select.filter(agent_message::Column::Seq.gt(after_seq));
62 }
63 select = select.order_by_asc(agent_message::Column::Seq);
64
65 let total = select.clone().count(self.db().read()).await? as i64;
66 let rows = select
67 .limit(limit)
68 .offset(offset)
69 .all(self.db().read())
70 .await?;
71
72 Ok(AgentMessagePage {
73 items: rows.into_iter().map(Self::message_dto).collect(),
74 total,
75 limit,
76 offset,
77 })
78 }
79}
Deleted lib/sagent/src/services/mod.rs lib/sagent/src/services/mod.rs +0 −13
@@ -1,13 +0,0 @@
1pub mod artifacts;
2pub mod collections;
3pub mod events;
4pub mod labels;
5pub mod members;
6pub mod messages;
7pub mod reads;
8pub mod repo_relations;
9pub mod service;
10pub mod subscribe;
11pub mod tasks;
12pub mod tokens;
13pub mod watches;
Deleted lib/sagent/src/services/reads.rs lib/sagent/src/services/reads.rs +0 −102
@@ -1,102 +0,0 @@
1use models::sagent::agent_user;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use utoipa::ToSchema;
6use uuid::Uuid;
7
8use crate::errors::SagentError;
9use crate::services::service::SagentService;
10use crate::share::lookup::task_context;
11
12#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
13pub struct AgentReadStateDto {
14 pub agent_id: Uuid,
15 pub user_id: Uuid,
16 pub last_read_seq: i64,
17 pub last_event_seq: i64,
18 pub unread: i64,
19 pub updated_at: Option<OffsetDateTime>,
20}
21
22#[derive(Debug, Clone, Deserialize, ToSchema)]
23pub struct UpdateReadStateCmd {
24 #[serde(default)]
25 pub last_read_seq: Option<i64>,
26}
27
28impl SagentService {
29 pub async fn get_read_state(
30 &self,
31 caller: Uuid,
32 space_name: &str,
33 collection_id: Uuid,
34 task_ref: &str,
35 ) -> Result<AgentReadStateDto, SagentError> {
36 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
37 let row = agent_user::Entity::find()
38 .filter(agent_user::Column::Uid.eq(caller))
39 .filter(agent_user::Column::AgentId.eq(task.id))
40 .one(self.db().read())
41 .await?;
42 let last_read_seq = row.as_ref().map(|row| row.last_read_seq).unwrap_or(0);
43 Ok(AgentReadStateDto {
44 agent_id: task.id,
45 user_id: caller,
46 last_read_seq,
47 last_event_seq: task.last_event_seq,
48 unread: (task.last_event_seq - last_read_seq).max(0),
49 updated_at: row.and_then(|row| row.updated_at),
50 })
51 }
52
53 pub async fn mark_read(
54 &self,
55 caller: Uuid,
56 space_name: &str,
57 collection_id: Uuid,
58 task_ref: &str,
59 cmd: UpdateReadStateCmd,
60 ) -> Result<AgentReadStateDto, SagentError> {
61 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
62 let target = cmd.last_read_seq.unwrap_or(task.last_event_seq).max(0);
63
64 let existing = agent_user::Entity::find()
65 .filter(agent_user::Column::Uid.eq(caller))
66 .filter(agent_user::Column::AgentId.eq(task.id))
67 .one(self.db().read())
68 .await?;
69
70 let now = OffsetDateTime::now_utc();
71 let row = match existing {
72 Some(row) => {
73 let last_read_seq = row.last_read_seq.max(target);
74 let mut update: agent_user::ActiveModel = row.into();
75 update.last_read_seq = Set(last_read_seq);
76 update.updated_at = Set(Some(now));
77 update.update(self.db().write()).await?
78 }
79 None => {
80 agent_user::ActiveModel {
81 id: Set(Uuid::now_v7()),
82 uid: Set(caller),
83 agent_id: Set(task.id),
84 last_read_seq: Set(target),
85 created_at: Set(now),
86 updated_at: Set(None),
87 }
88 .insert(self.db().write())
89 .await?
90 }
91 };
92
93 Ok(AgentReadStateDto {
94 agent_id: task.id,
95 user_id: caller,
96 last_read_seq: row.last_read_seq,
97 last_event_seq: task.last_event_seq,
98 unread: (task.last_event_seq - row.last_read_seq).max(0),
99 updated_at: row.updated_at,
100 })
101 }
102}
Deleted lib/sagent/src/services/repo_relations.rs lib/sagent/src/services/repo_relations.rs +0 −190
@@ -1,190 +0,0 @@
1use models::dir::dirs;
2use models::repo::repos;
3use models::sagent::agent_event_line::AgentEventType;
4use models::sagent::agent_relation_repo;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::{IntoParams, ToSchema};
11use uuid::Uuid;
12
13use crate::errors::SagentError;
14use crate::services::service::SagentService;
15use crate::share::authz::require_task_write;
16use crate::share::lookup::task_context;
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct AgentRepoRelDto {
20 pub id: Uuid,
21 pub agent_id: Uuid,
22 pub repo_id: Uuid,
23 pub repo_name: Option<String>,
24 pub ref_name: Option<String>,
25 pub sub_path: Option<String>,
26 pub created_at: OffsetDateTime,
27}
28
29#[derive(Debug, Clone, Deserialize, ToSchema)]
30pub struct CreateAgentRepoRelCmd {
31 pub repo_id: Uuid,
32 #[serde(default)]
33 pub ref_name: Option<String>,
34 #[serde(default)]
35 pub sub_path: Option<String>,
36}
37
38#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
39pub struct TaskRepoRelPath {
40 pub space_name: String,
41 pub collection_id: Uuid,
42 pub task_id: String,
43 pub rel_id: Uuid,
44}
45
46impl SagentService {
47 pub async fn create_repo_relation(
48 &self,
49 caller: Uuid,
50 space_name: &str,
51 collection_id: Uuid,
52 task_ref: &str,
53 cmd: CreateAgentRepoRelCmd,
54 ) -> Result<AgentRepoRelDto, SagentError> {
55 let (space, collection, task) =
56 task_context(self, caller, space_name, collection_id, task_ref).await?;
57 require_task_write(self, &space, caller, &task).await?;
58
59 let repo = repos::Entity::find_by_id(cmd.repo_id)
60 .one(self.db().read())
61 .await?
62 .ok_or(SagentError::NotFound)?;
63 let dir = dirs::Entity::find_by_id(repo.dir_id)
64 .one(self.db().read())
65 .await?
66 .ok_or(SagentError::NotFound)?;
67 if dir.space_id != Some(space.id) {
68 return Err(SagentError::NotFound);
69 }
70
71 let existing = agent_relation_repo::Entity::find()
72 .filter(agent_relation_repo::Column::AgentId.eq(task.id))
73 .filter(agent_relation_repo::Column::RepoId.eq(cmd.repo_id))
74 .one(self.db().read())
75 .await?;
76 if existing.is_some() {
77 return Err(SagentError::Conflict(
78 "repo relation already exists".to_string(),
79 ));
80 }
81
82 let txn = self.db().write().begin().await?;
83 let inserted = agent_relation_repo::ActiveModel {
84 id: Set(Uuid::now_v7()),
85 agent_id: Set(task.id),
86 repo_id: Set(cmd.repo_id),
87 ref_name: Set(cmd.ref_name),
88 sub_path: Set(cmd.sub_path),
89 created_at: Set(OffsetDateTime::now_utc()),
90 }
91 .insert(&txn)
92 .await?;
93 self.write_event(
94 &txn,
95 task.id,
96 collection.id,
97 Some(caller),
98 AgentEventType::RepoRelationAdded,
99 None,
100 Some(serde_json::json!({ "repo_id": cmd.repo_id, "repo_name": repo.name })),
101 )
102 .await?;
103 txn.commit().await?;
104
105 Ok(AgentRepoRelDto {
106 id: inserted.id,
107 agent_id: inserted.agent_id,
108 repo_id: inserted.repo_id,
109 repo_name: Some(repo.name),
110 ref_name: inserted.ref_name,
111 sub_path: inserted.sub_path,
112 created_at: inserted.created_at,
113 })
114 }
115
116 pub async fn list_repo_relations(
117 &self,
118 caller: Uuid,
119 space_name: &str,
120 collection_id: Uuid,
121 task_ref: &str,
122 ) -> Result<Vec<AgentRepoRelDto>, SagentError> {
123 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
124 let rows = agent_relation_repo::Entity::find()
125 .filter(agent_relation_repo::Column::AgentId.eq(task.id))
126 .order_by_asc(agent_relation_repo::Column::CreatedAt)
127 .all(self.db().read())
128 .await?;
129 if rows.is_empty() {
130 return Ok(Vec::new());
131 }
132 let repo_ids: Vec<Uuid> = rows.iter().map(|row| row.repo_id).collect();
133 let repos = repos::Entity::find()
134 .filter(repos::Column::Id.is_in(repo_ids))
135 .all(self.db().read())
136 .await?;
137 let names: std::collections::HashMap<Uuid, String> =
138 repos.into_iter().map(|repo| (repo.id, repo.name)).collect();
139
140 Ok(rows
141 .into_iter()
142 .map(|row| AgentRepoRelDto {
143 id: row.id,
144 agent_id: row.agent_id,
145 repo_id: row.repo_id,
146 repo_name: names.get(&row.repo_id).cloned(),
147 ref_name: row.ref_name,
148 sub_path: row.sub_path,
149 created_at: row.created_at,
150 })
151 .collect())
152 }
153
154 pub async fn delete_repo_relation(
155 &self,
156 caller: Uuid,
157 space_name: &str,
158 collection_id: Uuid,
159 task_ref: &str,
160 rel_id: Uuid,
161 ) -> Result<(), SagentError> {
162 let (space, collection, task) =
163 task_context(self, caller, space_name, collection_id, task_ref).await?;
164 require_task_write(self, &space, caller, &task).await?;
165
166 let row = agent_relation_repo::Entity::find_by_id(rel_id)
167 .filter(agent_relation_repo::Column::AgentId.eq(task.id))
168 .one(self.db().read())
169 .await?
170 .ok_or(SagentError::NotFound)?;
171
172 let txn = self.db().write().begin().await?;
173 agent_relation_repo::Entity::delete_by_id(row.id)
174 .exec(&txn)
175 .await?;
176 self.write_event(
177 &txn,
178 task.id,
179 collection.id,
180 Some(caller),
181 AgentEventType::RepoRelationRemoved,
182 None,
183 Some(serde_json::json!({ "repo_id": row.repo_id })),
184 )
185 .await?;
186 txn.commit().await?;
187
188 Ok(())
189 }
190}
Deleted lib/sagent/src/services/service.rs lib/sagent/src/services/service.rs +0 −163
@@ -1,163 +0,0 @@
1use std::collections::{HashMap, HashSet};
2use std::sync::{Arc, Mutex, OnceLock};
3
4use async_trait::async_trait;
5use config::AppConfig;
6use export::AppState;
7use export::database::AppDatabase;
8use models::sagent::agent_event_line::{self, AgentEventType};
9use models::users::user;
10use models::users::user_notifications::{self, NotificationType};
11use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set};
12use session::SessionError;
13use session::share::guard::{SessionGuard, SessionWithUser};
14use space::services::spaces::UserDto;
15use time::OffsetDateTime;
16use uuid::Uuid;
17
18use crate::errors::SagentError;
19use crate::gateway::AgentGateway;
20
21#[derive(Clone)]
22pub struct SagentService {
23 pub state: AppState,
24 gateway: Arc<OnceLock<Result<AgentGateway, String>>>,
25 subscribers: Arc<Mutex<HashSet<Uuid>>>,
26}
27
28impl SagentService {
29 pub fn new(state: AppState) -> Self {
30 Self {
31 state,
32 gateway: Arc::new(OnceLock::new()),
33 subscribers: Arc::new(Mutex::new(HashSet::new())),
34 }
35 }
36
37 pub(crate) fn claim_subscriber(&self, task_id: Uuid) -> bool {
38 let mut running = self
39 .subscribers
40 .lock()
41 .unwrap_or_else(|err| err.into_inner());
42 running.insert(task_id)
43 }
44
45 pub(crate) fn release_subscriber(&self, task_id: Uuid) {
46 let mut running = self
47 .subscribers
48 .lock()
49 .unwrap_or_else(|err| err.into_inner());
50 running.remove(&task_id);
51 }
52
53 pub fn config(&self) -> &AppConfig {
54 &self.state.config
55 }
56
57 pub fn db(&self) -> &Arc<AppDatabase> {
58 &self.state.database
59 }
60
61 pub fn paginate(&self, limit: Option<u64>, offset: Option<u64>) -> (u64, u64) {
62 let max_pagination = self.config().sagent.max_pagination;
63 let default_pagination = self.config().sagent.default_pagination;
64 (
65 limit.unwrap_or(default_pagination).clamp(1, max_pagination),
66 offset.unwrap_or(0).clamp(0, 5000),
67 )
68 }
69
70 pub fn gateway(&self) -> Result<&AgentGateway, SagentError> {
71 let cached = self.gateway.get_or_init(|| {
72 AgentGateway::new(&self.config().sagent).map_err(|error| error.to_string())
73 });
74 match cached {
75 Ok(gateway) => Ok(gateway),
76 Err(message) => Err(SagentError::Unavailable(message.clone())),
77 }
78 }
79
80 pub(crate) async fn write_event(
81 &self,
82 txn: &impl ConnectionTrait,
83 agent_id: Uuid,
84 collection_id: Uuid,
85 user_id: Option<Uuid>,
86 event_type: AgentEventType,
87 run_id: Option<Uuid>,
88 content: Option<serde_json::Value>,
89 ) -> Result<(), SagentError> {
90 agent_event_line::ActiveModel {
91 id: Set(Uuid::now_v7()),
92 agent_id: Set(Some(agent_id)),
93 collection_id: Set(Some(collection_id)),
94 user_id: Set(user_id),
95 event_type: Set(event_type),
96 run_id: Set(run_id),
97 content: Set(content),
98 created_at: Set(OffsetDateTime::now_utc()),
99 }
100 .insert(txn)
101 .await?;
102 Ok(())
103 }
104
105 pub(crate) async fn load_user_briefs(
106 &self,
107 uids: &[Uuid],
108 ) -> Result<HashMap<Uuid, UserDto>, SagentError> {
109 if uids.is_empty() {
110 return Ok(HashMap::new());
111 }
112 let mut set: Vec<Uuid> = uids.to_vec();
113 set.sort();
114 set.dedup();
115 let users = user::Entity::find()
116 .filter(user::Column::Uid.is_in(set))
117 .all(self.db().read())
118 .await?;
119 Ok(users
120 .into_iter()
121 .map(|row| (row.uid, UserDto::from(row)))
122 .collect())
123 }
124
125 #[allow(clippy::too_many_arguments)]
126 pub(crate) async fn notify_user(
127 &self,
128 txn: &impl ConnectionTrait,
129 pending: &export::notify::PendingNotifications,
130 recipient: Uuid,
131 ntype: NotificationType,
132 title: String,
133 body: Option<String>,
134 data: Option<serde_json::Value>,
135 actor_uid: Option<Uuid>,
136 ) -> Result<(), SagentError> {
137 let row = user_notifications::ActiveModel {
138 id: Set(Uuid::now_v7()),
139 user_id: Set(recipient),
140 notification_type: Set(ntype),
141 title: Set(title),
142 body: Set(body),
143 data_json: Set(data),
144 actor_uid: Set(actor_uid),
145 read_at: Set(None),
146 created_at: Set(OffsetDateTime::now_utc()),
147 }
148 .insert(txn)
149 .await?;
150 pending.push(recipient, row.to_event_value());
151 Ok(())
152 }
153}
154
155#[async_trait]
156impl SessionGuard for SagentService {
157 async fn lookup_session_with_user(
158 &self,
159 token_hash: &str,
160 ) -> Result<Option<SessionWithUser>, SessionError> {
161 session::lookup_session_with_user(self.db().read(), token_hash).await
162 }
163}
Deleted lib/sagent/src/services/subscribe.rs lib/sagent/src/services/subscribe.rs +0 −388
@@ -1,388 +0,0 @@
1use std::time::Duration;
2
3use agent_sdk::SseEvent;
4use agent_types::event::AgentEvent;
5use agent_types::session::{MessageDto, MessageRole};
6use models::sagent::agent;
7use models::sagent::agent_event_line::AgentEventType;
8use models::sagent::agent_message::{self, AgentRole};
9use sea_orm::sea_query::{Expr, OnConflict};
10use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
11use time::OffsetDateTime;
12use uuid::Uuid;
13
14use crate::errors::SagentError;
15use crate::gateway::{INVOKE_SCOPES, map_run_state};
16use crate::services::collections::ACTIVE_STATES;
17use crate::services::service::SagentService;
18
19const RECONNECT_BACKOFF_MS: u64 = 500;
20const RECONNECT_BACKOFF_MAX_MS: u64 = 8_000;
21
22fn backoff(attempt: u64) -> Duration {
23 let factor = 1u64 << attempt.min(4);
24 Duration::from_millis((RECONNECT_BACKOFF_MS * factor).min(RECONNECT_BACKOFF_MAX_MS))
25}
26
27fn millis_to_time(millis: i64) -> OffsetDateTime {
28 OffsetDateTime::from_unix_timestamp_nanos(i128::from(millis) * 1_000_000)
29 .unwrap_or_else(|_| OffsetDateTime::now_utc())
30}
31
32fn state_name(state: models::sagent::agent::AgentState) -> &'static str {
33 match state {
34 models::sagent::agent::AgentState::Draft => "draft",
35 models::sagent::agent::AgentState::Pending => "pending",
36 models::sagent::agent::AgentState::Planning => "planning",
37 models::sagent::agent::AgentState::Executing => "executing",
38 models::sagent::agent::AgentState::Waiting => "waiting",
39 models::sagent::agent::AgentState::Succeeded => "succeeded",
40 models::sagent::agent::AgentState::Failed => "failed",
41 models::sagent::agent::AgentState::Canceled => "canceled",
42 models::sagent::agent::AgentState::Closed => "closed",
43 }
44}
45
46impl SagentService {
47 pub(crate) async fn load_task(
48 &self,
49 task_id: Uuid,
50 ) -> Result<Option<agent::Model>, SagentError> {
51 Ok(agent::Entity::find_by_id(task_id)
52 .one(self.db().read())
53 .await?)
54 }
55
56 pub(crate) fn spawn_archive_subscriber(&self, task_id: Uuid) {
57 if !self.claim_subscriber(task_id) {
58 return;
59 }
60 let service = self.clone();
61 tokio::spawn(async move {
62 let result = service.run_archive_subscriber(task_id).await;
63 service.release_subscriber(task_id);
64 if let Err(error) = result {
65 tracing::error!(?error, task_id = %task_id, "sagent archive subscriber failed");
66 }
67 });
68 }
69
70 pub async fn run_reconcile_loop(self) {
71 let interval = self.config().sagent.reconcile_interval_secs.max(10);
72 loop {
73 tokio::time::sleep(Duration::from_secs(interval)).await;
74 if let Err(error) = self.reconcile_active_tasks().await {
75 tracing::warn!(?error, "sagent reconcile pass failed");
76 }
77 }
78 }
79
80 async fn run_archive_subscriber(&self, task_id: Uuid) -> Result<(), SagentError> {
81 let reconnect_max = u64::from(self.config().sagent.subscribe_reconnect_max);
82 let mut attempt: u64 = 0;
83 loop {
84 let Some(task) = self.load_task(task_id).await? else {
85 return Ok(());
86 };
87 if task.state.is_terminal() {
88 return Ok(());
89 }
90 let Some(session_id) = task.session_id else {
91 return Ok(());
92 };
93
94 match self.consume_stream(&task, session_id).await {
95 Ok(true) => return Ok(()),
96 Ok(false) => {}
97 Err(error) => {
98 tracing::warn!(?error, task_id = %task_id, "sagent subscriber disconnected");
99 if matches!(
100 error,
101 SagentError::Unauthorized | SagentError::Forbidden | SagentError::NotFound
102 ) {
103 self.mark_subscriber_lost(task_id, &error.to_string())
104 .await?;
105 return Ok(());
106 }
107 }
108 }
109
110 attempt += 1;
111 if attempt > reconnect_max {
112 self.mark_subscriber_lost(task_id, "reconnect budget exhausted")
113 .await?;
114 return Ok(());
115 }
116 tokio::time::sleep(backoff(attempt)).await;
117 }
118 }
119
120 async fn consume_stream(
121 &self,
122 task: &agent::Model,
123 session_id: Uuid,
124 ) -> Result<bool, SagentError> {
125 let gateway = self.gateway()?;
126 let handle = gateway
127 .open_session(session_id, task.author_id, &INVOKE_SCOPES)
128 .await?;
129 let from = u64::try_from(task.last_event_seq).unwrap_or(0);
130 let mut stream = handle.stream_events(Some(from)).await?;
131
132 while let Some(frame) = stream.next().await {
133 if self.apply_frame(task, frame?).await? {
134 return Ok(true);
135 }
136 }
137 Ok(false)
138 }
139
140 async fn apply_frame(&self, task: &agent::Model, event: SseEvent) -> Result<bool, SagentError> {
141 match event {
142 SseEvent::MessageAdded { message, .. } => {
143 self.record_message(task, &message).await?;
144 Ok(false)
145 }
146 SseEvent::RunEvent { run_id, seq, event } => {
147 let sealed = match &event {
148 AgentEvent::ArtifactSealed { artifact, .. } => Some(artifact.clone()),
149 _ => None,
150 };
151 let txn = self.db().write().begin().await?;
152 self.write_event(
153 &txn,
154 task.id,
155 task.collection_id,
156 None,
157 AgentEventType::RunEvent,
158 Some(run_id.as_uuid()),
159 Some(serde_json::json!({ "seq": seq, "event": event })),
160 )
161 .await?;
162 if let Some(artifact) = sealed {
163 let key = artifact.object.key.to_string();
164 let name = key.rsplit('/').next().unwrap_or(&key).to_string();
165 let size = artifact.size_bytes.min(i64::MAX as u64) as i64;
166 self.record_artifact(
167 &txn,
168 task.id,
169 Some(run_id.as_uuid()),
170 artifact.kind.as_str(),
171 &name,
172 None,
173 size,
174 &key,
175 )
176 .await?;
177 self.write_event(
178 &txn,
179 task.id,
180 task.collection_id,
181 None,
182 AgentEventType::ArtifactAdded,
183 Some(run_id.as_uuid()),
184 Some(serde_json::json!({
185 "name": name,
186 "kind": artifact.kind.as_str(),
187 "size": size,
188 })),
189 )
190 .await?;
191 }
192 txn.commit().await?;
193 Ok(false)
194 }
195 SseEvent::RunState { run_id, state } => {
196 self.apply_run_state(task, run_id.as_uuid(), state).await
197 }
198 SseEvent::Other { .. } => Ok(false),
199 }
200 }
201
202 async fn record_message(
203 &self,
204 task: &agent::Model,
205 message: &MessageDto,
206 ) -> Result<(), SagentError> {
207 let seq = message.seq.min(i64::MAX as u64) as i64;
208 if seq <= task.last_event_seq {
209 return Ok(());
210 }
211 let message_id = message.message_id.as_uuid();
212 let role = match message.role {
213 MessageRole::User => AgentRole::User,
214 MessageRole::Assistant => AgentRole::Agent,
215 };
216
217 let mut conflict =
218 OnConflict::columns([agent_message::Column::AgentId, agent_message::Column::Seq]);
219 conflict.do_nothing();
220
221 let txn = self.db().write().begin().await?;
222 agent_message::Entity::insert(agent_message::ActiveModel {
223 id: Set(message_id),
224 agent_id: Set(task.id),
225 seq: Set(seq),
226 gateway_message_id: Set(Some(message_id)),
227 author_id: Set(None),
228 role: Set(role),
229 content: Set(message.content.to_string()),
230 created_at: Set(millis_to_time(message.created_at_millis)),
231 updated_at: Set(None),
232 })
233 .on_conflict(conflict)
234 .exec(&txn)
235 .await?;
236
237 agent::Entity::update_many()
238 .col_expr(agent::Column::LastEventSeq, Expr::value(seq))
239 .col_expr(
240 agent::Column::UpdatedAt,
241 Expr::value(OffsetDateTime::now_utc()),
242 )
243 .filter(agent::Column::Id.eq(task.id))
244 .filter(agent::Column::LastEventSeq.lt(seq))
245 .exec(&txn)
246 .await?;
247
248 txn.commit().await?;
249 Ok(())
250 }
251
252 async fn apply_run_state(
253 &self,
254 task: &agent::Model,
255 run_id: Uuid,
256 state: agent_types::state::RunState,
257 ) -> Result<bool, SagentError> {
258 let mapped = map_run_state(state);
259 let terminal = mapped.is_terminal();
260 let now = OffsetDateTime::now_utc();
261
262 let txn = self.db().write().begin().await?;
263 let mut update = agent::Entity::update_many()
264 .col_expr(agent::Column::State, Expr::value(mapped))
265 .col_expr(agent::Column::UpdatedAt, Expr::value(now))
266 .filter(agent::Column::Id.eq(task.id))
267 .filter(agent::Column::State.ne(mapped));
268 if terminal {
269 update = update.col_expr(agent::Column::FinishedAt, Expr::value(Some(now)));
270 }
271 let changed = update.exec(&txn).await?.rows_affected > 0;
272
273 self.write_event(
274 &txn,
275 task.id,
276 task.collection_id,
277 None,
278 AgentEventType::StateChanged,
279 Some(run_id),
280 Some(serde_json::json!({ "state": mapped, "terminal": terminal })),
281 )
282 .await?;
283
284 let pending = export::notify::PendingNotifications::default();
285 if changed && terminal {
286 self.notify_watchers(&txn, &pending, task, mapped).await?;
287 }
288 txn.commit().await?;
289 pending.flush(&self.state.redis).await;
290
291 Ok(terminal)
292 }
293
294 async fn notify_watchers(
295 &self,
296 txn: &impl sea_orm::ConnectionTrait,
297 pending: &export::notify::PendingNotifications,
298 task: &agent::Model,
299 state: models::sagent::agent::AgentState,
300 ) -> Result<(), SagentError> {
301 use models::common::watch_mode::WatchMode;
302 use models::sagent::agent_watch;
303 use models::users::user_notifications::NotificationType;
304
305 let watchers = agent_watch::Entity::find()
306 .filter(agent_watch::Column::AgentId.eq(task.id))
307 .filter(agent_watch::Column::Mode.eq(WatchMode::Watching))
308 .all(self.db().read())
309 .await?;
310
311 let data = serde_json::json!({
312 "collection_id": task.collection_id,
313 "agent_id": task.id,
314 "number": task.autoincrement_id,
315 "state": state,
316 });
317 for watcher in watchers {
318 self.notify_user(
319 txn,
320 pending,
321 watcher.user_id,
322 NotificationType::System,
323 format!(
324 "Agent task #{} {}: {}",
325 task.autoincrement_id,
326 state_name(state),
327 task.title
328 ),
329 Some(task.title.clone()),
330 Some(data.clone()),
331 None,
332 )
333 .await?;
334 }
335 Ok(())
336 }
337
338 async fn mark_subscriber_lost(&self, task_id: Uuid, reason: &str) -> Result<(), SagentError> {
339 let Some(task) = self.load_task(task_id).await? else {
340 return Ok(());
341 };
342 let txn = self.db().write().begin().await?;
343 self.write_event(
344 &txn,
345 task.id,
346 task.collection_id,
347 None,
348 AgentEventType::SubscriberLost,
349 task.last_run_id,
350 Some(serde_json::json!({ "reason": reason })),
351 )
352 .await?;
353 txn.commit().await?;
354 tracing::error!(task_id = %task_id, reason, "sagent archive subscriber lost");
355 Ok(())
356 }
357
358 async fn reconcile_active_tasks(&self) -> Result<(), SagentError> {
359 let tasks = agent::Entity::find()
360 .filter(agent::Column::State.is_in(ACTIVE_STATES))
361 .filter(agent::Column::SessionId.is_not_null())
362 .all(self.db().read())
363 .await?;
364
365 for task in tasks {
366 let Some(session_id) = task.session_id else {
367 continue;
368 };
369 let gateway = self.gateway()?;
370 let handle = gateway
371 .open_session(session_id, task.author_id, &INVOKE_SCOPES)
372 .await?;
373
374 let from = u64::try_from(task.last_event_seq).unwrap_or(0);
375 let cursor = if from == 0 { None } else { Some(from) };
376 let page = handle.list_messages(Some(200), cursor).await?;
377 for message in page.items {
378 self.record_message(&task, &message).await?;
379 }
380
381 if let Some(run_id) = task.last_run_id {
382 let run = handle.get_run(run_id).await?;
383 self.apply_run_state(&task, run_id, run.state).await?;
384 }
385 }
386 Ok(())
387 }
388}
Deleted lib/sagent/src/services/tasks.rs lib/sagent/src/services/tasks.rs +0 −663
@@ -1,663 +0,0 @@
1use models::sagent::agent::{AgentPriority, AgentState};
2use models::sagent::agent_member::{self, AgentMemberKind};
3use models::sagent::{
4 agent, agent_artifact, agent_event_line, agent_message, agent_relation_label,
5 agent_relation_repo, agent_user, agent_watch,
6};
7use sea_orm::sea_query::LockType;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
10 QuerySelect, Set, TransactionTrait, Unchanged,
11};
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use utoipa::{IntoParams, ToSchema};
15use uuid::Uuid;
16
17use crate::errors::SagentError;
18use crate::gateway::{INVOKE_SCOPES, map_run_state};
19use crate::services::service::SagentService;
20use crate::share::authz::{require_task_draft, require_task_write};
21use crate::share::lookup::{space_context, task_context};
22
23#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
24pub struct AgentDto {
25 pub id: Uuid,
26 pub collection_id: Uuid,
27 pub number: i32,
28 pub author_id: Uuid,
29 pub title: String,
30 pub description: Option<String>,
31 pub body: String,
32 pub state: AgentState,
33 pub priority: AgentPriority,
34 pub due_at: Option<OffsetDateTime>,
35 pub session_id: Option<Uuid>,
36 pub last_run_id: Option<Uuid>,
37 pub last_event_seq: i64,
38 pub created_at: OffsetDateTime,
39 pub updated_at: Option<OffsetDateTime>,
40 pub started_at: Option<OffsetDateTime>,
41 pub finished_at: Option<OffsetDateTime>,
42 pub closed_at: Option<OffsetDateTime>,
43}
44
45pub type AgentPage = export::pagination::Page<AgentDto>;
46
47#[derive(Debug, Clone, Deserialize, ToSchema)]
48pub struct CreateAgentCmd {
49 pub title: String,
50 #[serde(default)]
51 pub description: Option<String>,
52 #[serde(default)]
53 pub body: Option<String>,
54 #[serde(default)]
55 pub priority: AgentPriority,
56 #[serde(default)]
57 pub due_at: Option<OffsetDateTime>,
58}
59
60#[derive(Debug, Clone, Deserialize, ToSchema)]
61pub struct UpdateAgentCmd {
62 #[serde(default)]
63 pub title: Option<String>,
64 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
65 pub description: Option<Option<String>>,
66 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
67 pub body: Option<Option<String>>,
68 #[serde(default)]
69 pub priority: Option<AgentPriority>,
70 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
71 pub due_at: Option<Option<OffsetDateTime>>,
72}
73
74#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
75pub struct ListAgentQuery {
76 pub q: Option<String>,
77 #[param(value_type = String, required = false)]
78 pub state: Option<AgentState>,
79 pub priority: Option<AgentPriority>,
80 pub author_id: Option<Uuid>,
81 pub assignee_id: Option<Uuid>,
82 pub label_id: Option<Uuid>,
83 pub sort: Option<String>,
84 pub limit: Option<u64>,
85 pub offset: Option<u64>,
86}
87
88#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
89pub struct CollectionRef {
90 pub space_name: String,
91 pub collection_id: Uuid,
92}
93
94#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
95pub struct AgentPath {
96 pub space_name: String,
97 pub collection_id: Uuid,
98 pub task_id: String,
99}
100
101impl SagentService {
102 pub(crate) fn agent_dto(task: agent::Model) -> AgentDto {
103 AgentDto {
104 id: task.id,
105 collection_id: task.collection_id,
106 number: task.autoincrement_id,
107 author_id: task.author_id,
108 title: task.title,
109 description: task.description,
110 body: task.body,
111 state: task.state,
112 priority: task.priority,
113 due_at: task.due_at,
114 session_id: task.session_id,
115 last_run_id: task.last_run_id,
116 last_event_seq: task.last_event_seq,
117 created_at: task.created_at,
118 updated_at: task.updated_at,
119 started_at: task.started_at,
120 finished_at: task.finished_at,
121 closed_at: task.closed_at,
122 }
123 }
124
125 async fn set_state(&self, task_id: Uuid, state: AgentState) -> Result<(), SagentError> {
126 let update = agent::ActiveModel {
127 id: Unchanged(task_id),
128 state: Set(state),
129 updated_at: Set(Some(OffsetDateTime::now_utc())),
130 ..Default::default()
131 };
132 update.update(self.db().write()).await?;
133 Ok(())
134 }
135
136 pub async fn create_task(
137 &self,
138 caller: Uuid,
139 space_name: &str,
140 collection_id: Uuid,
141 cmd: CreateAgentCmd,
142 ) -> Result<AgentDto, SagentError> {
143 let space = space_context(self, caller, space_name).await?;
144 let collection =
145 crate::share::lookup::lookup_collection(self, &space, collection_id, caller).await?;
146
147 let title = cmd.title.trim();
148 if title.is_empty() || title.chars().count() > self.config().sagent.max_title_len {
149 return Err(SagentError::BadRequest(
150 "task title is empty or too long".to_string(),
151 ));
152 }
153
154 let body = cmd.body.unwrap_or_default();
155 if body.chars().count() > self.config().sagent.max_body_len {
156 return Err(SagentError::BadRequest("task body is too long".to_string()));
157 }
158
159 let now = OffsetDateTime::now_utc();
160 let task = {
161 let txn = self.db().write().begin().await?;
162 let max_row = agent::Entity::find()
163 .filter(agent::Column::CollectionId.eq(collection.id))
164 .order_by_desc(agent::Column::AutoincrementId)
165 .lock(LockType::Update)
166 .one(&txn)
167 .await?;
168 let number = max_row.map(|row| row.autoincrement_id + 1).unwrap_or(1);
169
170 let row = agent::ActiveModel {
171 id: Set(Uuid::now_v7()),
172 collection_id: Set(collection.id),
173 autoincrement_id: Set(number),
174 author_id: Set(caller),
175 title: Set(title.to_string()),
176 description: Set(cmd.description),
177 body: Set(body),
178 state: Set(AgentState::Draft),
179 priority: Set(cmd.priority),
180 due_at: Set(cmd.due_at),
181 session_id: Set(None),
182 last_run_id: Set(None),
183 last_event_seq: Set(0),
184 created_at: Set(now),
185 updated_at: Set(None),
186 started_at: Set(None),
187 finished_at: Set(None),
188 closed_at: Set(None),
189 }
190 .insert(&txn)
191 .await?;
192
193 self.write_event(
194 &txn,
195 row.id,
196 collection.id,
197 Some(caller),
198 agent_event_line::AgentEventType::Created,
199 None,
200 None,
201 )
202 .await?;
203
204 txn.commit().await?;
205 row
206 };
207
208 Ok(Self::agent_dto(task))
209 }
210
211 pub async fn list_tasks(
212 &self,
213 caller: Uuid,
214 space_name: &str,
215 collection_id: Uuid,
216 query: ListAgentQuery,
217 ) -> Result<AgentPage, SagentError> {
218 let space = space_context(self, caller, space_name).await?;
219 crate::share::lookup::lookup_collection(self, &space, collection_id, caller).await?;
220 let (limit, offset) = self.paginate(query.limit, query.offset);
221
222 let mut select =
223 agent::Entity::find().filter(agent::Column::CollectionId.eq(collection_id));
224 if let Some(search) = query.q {
225 let keyword = search.trim();
226 if !keyword.is_empty() {
227 select = select.filter(agent::Column::Title.contains(keyword));
228 }
229 }
230 if let Some(state) = query.state {
231 select = select.filter(agent::Column::State.eq(state));
232 }
233 if let Some(priority) = query.priority {
234 select = select.filter(agent::Column::Priority.eq(priority));
235 }
236 if let Some(author_id) = query.author_id {
237 select = select.filter(agent::Column::AuthorId.eq(author_id));
238 }
239 if let Some(assignee_id) = query.assignee_id {
240 let task_ids: Vec<Uuid> = agent_member::Entity::find()
241 .filter(agent_member::Column::UserId.eq(assignee_id))
242 .filter(agent_member::Column::Kind.eq(AgentMemberKind::Assignee))
243 .all(self.db().read())
244 .await?
245 .into_iter()
246 .map(|row| row.agent_id)
247 .collect();
248 select = select.filter(agent::Column::Id.is_in(task_ids));
249 }
250 if let Some(label_id) = query.label_id {
251 let task_ids: Vec<Uuid> = agent_relation_label::Entity::find()
252 .filter(agent_relation_label::Column::LabelId.eq(label_id))
253 .all(self.db().read())
254 .await?
255 .into_iter()
256 .map(|row| row.agent_id)
257 .collect();
258 select = select.filter(agent::Column::Id.is_in(task_ids));
259 }
260
261 select = match query.sort.as_deref() {
262 Some("updated") => select.order_by_desc(agent::Column::UpdatedAt),
263 Some("priority") => select
264 .order_by_desc(agent::Column::Priority)
265 .order_by_desc(agent::Column::CreatedAt),
266 Some("number") => select.order_by_asc(agent::Column::AutoincrementId),
267 _ => select.order_by_desc(agent::Column::CreatedAt),
268 };
269
270 let total = select.clone().count(self.db().read()).await? as i64;
271 let rows = select
272 .limit(limit)
273 .offset(offset)
274 .all(self.db().read())
275 .await?;
276
277 Ok(AgentPage {
278 items: rows.into_iter().map(Self::agent_dto).collect(),
279 total,
280 limit,
281 offset,
282 })
283 }
284
285 pub async fn get_task(
286 &self,
287 caller: Uuid,
288 space_name: &str,
289 collection_id: Uuid,
290 task_ref: &str,
291 ) -> Result<AgentDto, SagentError> {
292 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
293 Ok(Self::agent_dto(task))
294 }
295
296 pub async fn update_task(
297 &self,
298 caller: Uuid,
299 space_name: &str,
300 collection_id: Uuid,
301 task_ref: &str,
302 cmd: UpdateAgentCmd,
303 ) -> Result<AgentDto, SagentError> {
304 let (space, collection, task) =
305 task_context(self, caller, space_name, collection_id, task_ref).await?;
306 require_task_write(self, &space, caller, &task).await?;
307
308 let now = OffsetDateTime::now_utc();
309 let mut update: agent::ActiveModel = task.clone().into();
310
311 if let Some(new_title) = cmd.title {
312 let title = new_title.trim();
313 if title.is_empty() || title.chars().count() > self.config().sagent.max_title_len {
314 return Err(SagentError::BadRequest(
315 "task title is empty or too long".to_string(),
316 ));
317 }
318 update.title = Set(title.to_string());
319 }
320 if let Some(description) = cmd.description {
321 update.description = Set(description);
322 }
323 if let Some(body) = cmd.body {
324 require_task_draft(task.state)?;
325 let body = body.unwrap_or_default();
326 if body.chars().count() > self.config().sagent.max_body_len {
327 return Err(SagentError::BadRequest("task body is too long".to_string()));
328 }
329 update.body = Set(body);
330 }
331 if let Some(priority) = cmd.priority {
332 update.priority = Set(priority);
333 }
334 if let Some(due_at) = cmd.due_at {
335 update.due_at = Set(due_at);
336 }
337 update.updated_at = Set(Some(now));
338
339 let updated = {
340 let txn = self.db().write().begin().await?;
341 let updated = update.update(&txn).await?;
342 self.write_event(
343 &txn,
344 task.id,
345 collection.id,
346 Some(caller),
347 agent_event_line::AgentEventType::BodyUpdated,
348 None,
349 None,
350 )
351 .await?;
352 txn.commit().await?;
353 updated
354 };
355
356 Ok(Self::agent_dto(updated))
357 }
358
359 pub async fn delete_task(
360 &self,
361 caller: Uuid,
362 space_name: &str,
363 collection_id: Uuid,
364 task_ref: &str,
365 ) -> Result<(), SagentError> {
366 let (space, _, task) =
367 task_context(self, caller, space_name, collection_id, task_ref).await?;
368 require_task_write(self, &space, caller, &task).await?;
369
370 self.db()
371 .primary()
372 .transaction::<_, (), SagentError>(|txn| {
373 Box::pin(async move {
374 agent_artifact::Entity::delete_many()
375 .filter(agent_artifact::Column::AgentId.eq(task.id))
376 .exec(txn)
377 .await?;
378 agent_relation_repo::Entity::delete_many()
379 .filter(agent_relation_repo::Column::AgentId.eq(task.id))
380 .exec(txn)
381 .await?;
382 agent_relation_label::Entity::delete_many()
383 .filter(agent_relation_label::Column::AgentId.eq(task.id))
384 .exec(txn)
385 .await?;
386 agent_user::Entity::delete_many()
387 .filter(agent_user::Column::AgentId.eq(task.id))
388 .exec(txn)
389 .await?;
390 agent_member::Entity::delete_many()
391 .filter(agent_member::Column::AgentId.eq(task.id))
392 .exec(txn)
393 .await?;
394 agent_message::Entity::delete_many()
395 .filter(agent_message::Column::AgentId.eq(task.id))
396 .exec(txn)
397 .await?;
398 agent_watch::Entity::delete_many()
399 .filter(agent_watch::Column::AgentId.eq(task.id))
400 .exec(txn)
401 .await?;
402 agent_event_line::Entity::delete_many()
403 .filter(agent_event_line::Column::AgentId.eq(task.id))
404 .exec(txn)
405 .await?;
406 agent::Entity::delete_by_id(task.id).exec(txn).await?;
407 Ok(())
408 })
409 })
410 .await?;
411
412 Ok(())
413 }
414
415 pub async fn close_task(
416 &self,
417 caller: Uuid,
418 space_name: &str,
419 collection_id: Uuid,
420 task_ref: &str,
421 ) -> Result<AgentDto, SagentError> {
422 let (space, collection, task) =
423 task_context(self, caller, space_name, collection_id, task_ref).await?;
424 require_task_write(self, &space, caller, &task).await?;
425
426 if task.state == AgentState::Closed {
427 return Err(SagentError::Conflict("task is already closed".to_string()));
428 }
429
430 let now = OffsetDateTime::now_utc();
431 let update = agent::ActiveModel {
432 id: Unchanged(task.id),
433 state: Set(AgentState::Closed),
434 closed_at: Set(Some(now)),
435 updated_at: Set(Some(now)),
436 ..Default::default()
437 };
438
439 let updated = {
440 let txn = self.db().write().begin().await?;
441 let updated = update.update(&txn).await?;
442 self.write_event(
443 &txn,
444 task.id,
445 collection.id,
446 Some(caller),
447 agent_event_line::AgentEventType::Closed,
448 None,
449 None,
450 )
451 .await?;
452 txn.commit().await?;
453 updated
454 };
455
456 Ok(Self::agent_dto(updated))
457 }
458
459 pub async fn reopen_task(
460 &self,
461 caller: Uuid,
462 space_name: &str,
463 collection_id: Uuid,
464 task_ref: &str,
465 ) -> Result<AgentDto, SagentError> {
466 let (space, collection, task) =
467 task_context(self, caller, space_name, collection_id, task_ref).await?;
468 require_task_write(self, &space, caller, &task).await?;
469
470 if task.state != AgentState::Closed {
471 return Err(SagentError::Conflict("task is not closed".to_string()));
472 }
473
474 let now = OffsetDateTime::now_utc();
475 let update = agent::ActiveModel {
476 id: Unchanged(task.id),
477 state: Set(AgentState::Draft),
478 closed_at: Set(None),
479 updated_at: Set(Some(now)),
480 ..Default::default()
481 };
482
483 let updated = {
484 let txn = self.db().write().begin().await?;
485 let updated = update.update(&txn).await?;
486 self.write_event(
487 &txn,
488 task.id,
489 collection.id,
490 Some(caller),
491 agent_event_line::AgentEventType::Reopened,
492 None,
493 None,
494 )
495 .await?;
496 txn.commit().await?;
497 updated
498 };
499
500 Ok(Self::agent_dto(updated))
501 }
502
503 pub async fn start_task(
504 &self,
505 caller: Uuid,
506 space_name: &str,
507 collection_id: Uuid,
508 task_ref: &str,
509 ) -> Result<AgentDto, SagentError> {
510 let (space, collection, task) =
511 task_context(self, caller, space_name, collection_id, task_ref).await?;
512 require_task_write(self, &space, caller, &task).await?;
513
514 if !matches!(task.state, AgentState::Draft | AgentState::Pending) {
515 return Err(SagentError::Conflict(
516 "task has already been started".to_string(),
517 ));
518 }
519 if task.body.trim().is_empty() {
520 return Err(SagentError::BadRequest("task body is empty".to_string()));
521 }
522
523 let previous_state = task.state;
524 {
525 let txn = self.db().write().begin().await?;
526 let current = agent::Entity::find_by_id(task.id)
527 .filter(agent::Column::State.is_in([AgentState::Draft, AgentState::Pending]))
528 .lock(LockType::Update)
529 .one(&txn)
530 .await?;
531 let Some(current) = current else {
532 return Err(SagentError::Conflict(
533 "task has already been started".to_string(),
534 ));
535 };
536 let mut update: agent::ActiveModel = current.into();
537 update.state = Set(AgentState::Pending);
538 update.updated_at = Set(Some(OffsetDateTime::now_utc()));
539 update.update(&txn).await?;
540 txn.commit().await?;
541 }
542
543 match self
544 .dispatch_task(caller, &space.id, &collection, &task)
545 .await
546 {
547 Ok(dto) => Ok(dto),
548 Err(error) => {
549 if let Err(restore) = self.set_state(task.id, previous_state).await {
550 tracing::error!(?restore, task_id = %task.id, "failed to restore task state");
551 }
552 Err(error)
553 }
554 }
555 }
556
557 async fn dispatch_task(
558 &self,
559 caller: Uuid,
560 space_id: &Uuid,
561 collection: &models::sagent::agents::Model,
562 task: &agent::Model,
563 ) -> Result<AgentDto, SagentError> {
564 let gateway = self.gateway()?;
565 let title = format!(
566 "{} #{} {}",
567 collection.name, task.autoincrement_id, task.title
568 );
569 let metadata = serde_json::json!({
570 "task_id": task.id,
571 "collection_id": collection.id,
572 "space_id": space_id,
573 });
574
575 let session = gateway
576 .ensure_session(task.session_id, &title, Some(metadata))
577 .await?;
578 let session_id = session.session_id.as_uuid();
579
580 let handle = gateway
581 .open_session(session_id, caller, &INVOKE_SCOPES)
582 .await?;
583 let accepted = handle.send_message(task.body.clone(), None).await?;
584 let run_id = accepted.run_id.as_uuid();
585 let now = OffsetDateTime::now_utc();
586
587 let updated = {
588 let txn = self.db().write().begin().await?;
589 let mut update: agent::ActiveModel = task.clone().into();
590 update.session_id = Set(Some(session_id));
591 update.last_run_id = Set(Some(run_id));
592 update.state = Set(map_run_state(accepted.state));
593 update.started_at = Set(Some(task.started_at.unwrap_or(now)));
594 update.finished_at = Set(None);
595 update.updated_at = Set(Some(now));
596 let updated = update.update(&txn).await?;
597 self.write_event(
598 &txn,
599 task.id,
600 collection.id,
601 Some(caller),
602 agent_event_line::AgentEventType::Started,
603 Some(run_id),
604 Some(serde_json::json!({ "state": accepted.state })),
605 )
606 .await?;
607 txn.commit().await?;
608 updated
609 };
610
611 self.spawn_archive_subscriber(updated.id);
612 Ok(Self::agent_dto(updated))
613 }
614
615 pub async fn cancel_task(
616 &self,
617 caller: Uuid,
618 space_name: &str,
619 collection_id: Uuid,
620 task_ref: &str,
621 ) -> Result<AgentDto, SagentError> {
622 let (space, collection, task) =
623 task_context(self, caller, space_name, collection_id, task_ref).await?;
624 require_task_write(self, &space, caller, &task).await?;
625
626 let session_id = task
627 .session_id
628 .ok_or_else(|| SagentError::Conflict("task has not been started".to_string()))?;
629 let run_id = task
630 .last_run_id
631 .ok_or_else(|| SagentError::Conflict("task has no run".to_string()))?;
632
633 let gateway = self.gateway()?;
634 let handle = gateway
635 .open_session(session_id, caller, &INVOKE_SCOPES)
636 .await?;
637 let run = handle.cancel_run(run_id).await?;
638
639 let now = OffsetDateTime::now_utc();
640 let updated = {
641 let txn = self.db().write().begin().await?;
642 let mut update: agent::ActiveModel = task.clone().into();
643 update.state = Set(AgentState::Canceled);
644 update.finished_at = Set(Some(now));
645 update.updated_at = Set(Some(now));
646 let updated = update.update(&txn).await?;
647 self.write_event(
648 &txn,
649 task.id,
650 collection.id,
651 Some(caller),
652 agent_event_line::AgentEventType::StateChanged,
653 Some(run_id),
654 Some(serde_json::json!({ "state": run.state })),
655 )
656 .await?;
657 txn.commit().await?;
658 updated
659 };
660
661 Ok(Self::agent_dto(updated))
662 }
663}
Deleted lib/sagent/src/services/tokens.rs lib/sagent/src/services/tokens.rs +0 −46
@@ -1,46 +0,0 @@
1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use utoipa::ToSchema;
4use uuid::Uuid;
5
6use crate::errors::SagentError;
7use crate::gateway::INVOKE_SCOPES;
8use crate::services::service::SagentService;
9use crate::share::lookup::task_context;
10
11#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
12pub struct TaskTokenDto {
13 pub session_id: Uuid,
14 pub token: String,
15 pub expires_at: OffsetDateTime,
16 pub base_url: String,
17}
18
19impl SagentService {
20 pub async fn sign_task_token(
21 &self,
22 caller: Uuid,
23 space_name: &str,
24 collection_id: Uuid,
25 task_ref: &str,
26 ) -> Result<TaskTokenDto, SagentError> {
27 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
28 let session_id = task
29 .session_id
30 .ok_or_else(|| SagentError::Conflict("task has not been started".to_string()))?;
31
32 let gateway = self.gateway()?;
33 let handle = gateway
34 .open_session(session_id, caller, &INVOKE_SCOPES)
35 .await?;
36
37 self.spawn_archive_subscriber(task.id);
38
39 Ok(TaskTokenDto {
40 session_id,
41 token: handle.token().to_string(),
42 expires_at: handle.expires_at(),
43 base_url: gateway.base().to_string(),
44 })
45 }
46}
Deleted lib/sagent/src/services/watches.rs lib/sagent/src/services/watches.rs +0 −76
@@ -1,76 +0,0 @@
1use models::common::watch_mode::WatchMode;
2use models::sagent::agent_watch;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
4use serde::{Deserialize, Serialize};
5use time::OffsetDateTime;
6use utoipa::ToSchema;
7use uuid::Uuid;
8
9use crate::errors::SagentError;
10use crate::services::service::SagentService;
11use crate::share::lookup::task_context;
12
13#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
14pub struct AgentWatchDto {
15 pub id: Uuid,
16 pub agent_id: Uuid,
17 pub user_id: Uuid,
18 pub mode: WatchMode,
19 pub created_at: OffsetDateTime,
20 pub updated_at: Option<OffsetDateTime>,
21}
22
23#[derive(Debug, Clone, Deserialize, ToSchema)]
24pub struct UpsertWatchCmd {
25 pub mode: WatchMode,
26}
27
28impl SagentService {
29 pub async fn upsert_watch(
30 &self,
31 caller: Uuid,
32 space_name: &str,
33 collection_id: Uuid,
34 task_ref: &str,
35 cmd: UpsertWatchCmd,
36 ) -> Result<AgentWatchDto, SagentError> {
37 let (_, _, task) = task_context(self, caller, space_name, collection_id, task_ref).await?;
38 let now = OffsetDateTime::now_utc();
39
40 let existing = agent_watch::Entity::find()
41 .filter(agent_watch::Column::AgentId.eq(task.id))
42 .filter(agent_watch::Column::UserId.eq(caller))
43 .one(self.db().read())
44 .await?;
45
46 let row = match existing {
47 Some(row) => {
48 let mut update: agent_watch::ActiveModel = row.into();
49 update.mode = Set(cmd.mode);
50 update.updated_at = Set(Some(now));
51 update.update(self.db().write()).await?
52 }
53 None => {
54 agent_watch::ActiveModel {
55 id: Set(Uuid::now_v7()),
56 agent_id: Set(task.id),
57 user_id: Set(caller),
58 mode: Set(cmd.mode),
59 created_at: Set(now),
60 updated_at: Set(None),
61 }
62 .insert(self.db().write())
63 .await?
64 }
65 };
66
67 Ok(AgentWatchDto {
68 id: row.id,
69 agent_id: row.agent_id,
70 user_id: row.user_id,
71 mode: row.mode,
72 created_at: row.created_at,
73 updated_at: row.updated_at,
74 })
75 }
76}
Deleted lib/sagent/src/share/authz.rs lib/sagent/src/share/authz.rs +0 −56
@@ -1,56 +0,0 @@
1use models::sagent::agent::AgentState;
2use models::space::spaces;
3use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
4use uuid::Uuid;
5
6use crate::errors::SagentError;
7use crate::services::service::SagentService;
8
9pub async fn require_collection_manage(
10 service: &SagentService,
11 space: &spaces::Model,
12 caller: Uuid,
13) -> Result<(), SagentError> {
14 if is_collection_manager(service, space, caller).await? {
15 Ok(())
16 } else {
17 Err(SagentError::Forbidden)
18 }
19}
20
21pub async fn require_task_write(
22 service: &SagentService,
23 space: &spaces::Model,
24 caller: Uuid,
25 task: &models::sagent::agent::Model,
26) -> Result<(), SagentError> {
27 if task.author_id == caller || is_collection_manager(service, space, caller).await? {
28 Ok(())
29 } else {
30 Err(SagentError::Forbidden)
31 }
32}
33
34pub fn require_task_draft(state: AgentState) -> Result<(), SagentError> {
35 if state == AgentState::Draft {
36 Ok(())
37 } else {
38 Err(SagentError::Conflict(
39 "task body is frozen after the first run".to_string(),
40 ))
41 }
42}
43
44pub async fn is_collection_manager(
45 service: &SagentService,
46 space: &spaces::Model,
47 caller: Uuid,
48) -> Result<bool, SagentError> {
49 if space.owner_id == caller {
50 return Ok(true);
51 }
52 let perms = effective_permissions(service.db().read(), space, caller)
53 .await
54 .map_err(SagentError::from)?;
55 Ok(perms.has(MANAGE_CHANNELS))
56}
Deleted lib/sagent/src/share/guard.rs lib/sagent/src/share/guard.rs +0 −16
@@ -1,16 +0,0 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::Response;
4use session::Session;
5use session::share::guard::session_guard;
6
7use crate::services::service::SagentService;
8
9pub async fn sagent_guard(
10 State(service): State<SagentService>,
11 session: Session,
12 request: Request,
13 next: Next,
14) -> Response {
15 session_guard::<SagentService>(State(service), session, request, next).await
16}
Deleted lib/sagent/src/share/lookup.rs lib/sagent/src/share/lookup.rs +0 −140
@@ -1,140 +0,0 @@
1use models::sagent::{agent, agents};
2use models::space::spaces;
3use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
4use space::share::authz::{READ, VIEW_SPACE, effective_permissions};
5use uuid::Uuid;
6
7use crate::errors::SagentError;
8use crate::services::service::SagentService;
9
10pub async fn lookup_space_by_name(
11 service: &SagentService,
12 space_name: &str,
13) -> Result<spaces::Model, SagentError> {
14 let lower = space_name.trim().to_lowercase();
15 if lower.is_empty() {
16 return Err(SagentError::NotFound);
17 }
18 spaces::Entity::find()
19 .filter(spaces::Column::LowerName.eq(&lower))
20 .filter(spaces::Column::IsActive.eq(true))
21 .one(service.db().read())
22 .await?
23 .ok_or(SagentError::NotFound)
24}
25
26pub async fn require_member(
27 service: &SagentService,
28 space: &spaces::Model,
29 caller: Uuid,
30) -> Result<(), SagentError> {
31 let perms = effective_permissions(service.db().read(), space, caller)
32 .await
33 .map_err(SagentError::from)?;
34 if perms.has(VIEW_SPACE) || perms.has(READ) {
35 Ok(())
36 } else {
37 Err(SagentError::Forbidden)
38 }
39}
40
41pub async fn space_context(
42 service: &SagentService,
43 caller: Uuid,
44 space_name: &str,
45) -> Result<spaces::Model, SagentError> {
46 let space = lookup_space_by_name(service, space_name).await?;
47 require_member(service, &space, caller).await?;
48 Ok(space)
49}
50
51pub async fn lookup_collection(
52 service: &SagentService,
53 space: &spaces::Model,
54 collection_id: Uuid,
55 caller: Uuid,
56) -> Result<agents::Model, SagentError> {
57 let collection = agents::Entity::find_by_id(collection_id)
58 .one(service.db().read())
59 .await?
60 .ok_or(SagentError::NotFound)?;
61
62 let visible = models::dir::dirs::Entity::find_by_id(collection.dir_id)
63 .filter(models::dir::dirs::Column::SpaceId.eq(Some(space.id)))
64 .filter(models::dir::dirs::Column::Kind.eq(models::dir::dirs::DirKind::Object))
65 .one(service.db().read())
66 .await?
67 .is_some();
68 if !visible {
69 return Err(SagentError::NotFound);
70 }
71
72 if !space::share::dir_visibility::is_dir_visible(
73 service.db().read(),
74 space,
75 collection.dir_id,
76 caller,
77 )
78 .await?
79 {
80 return Err(SagentError::NotFound);
81 }
82
83 Ok(collection)
84}
85
86pub async fn lookup_task(
87 service: &SagentService,
88 collection: &agents::Model,
89 task_id: Uuid,
90) -> Result<agent::Model, SagentError> {
91 agent::Entity::find_by_id(task_id)
92 .filter(agent::Column::CollectionId.eq(collection.id))
93 .one(service.db().read())
94 .await?
95 .ok_or(SagentError::NotFound)
96}
97
98pub async fn lookup_task_by_ref(
99 service: &SagentService,
100 collection: &agents::Model,
101 task_ref: &str,
102) -> Result<agent::Model, SagentError> {
103 let base = agent::Entity::find().filter(agent::Column::CollectionId.eq(collection.id));
104 let found = if let Ok(id) = Uuid::parse_str(task_ref) {
105 base.filter(agent::Column::Id.eq(id))
106 .one(service.db().read())
107 .await?
108 } else if let Ok(number) = task_ref.parse::<i32>() {
109 base.filter(agent::Column::AutoincrementId.eq(number))
110 .one(service.db().read())
111 .await?
112 } else {
113 None
114 };
115 found.ok_or(SagentError::NotFound)
116}
117
118pub async fn collection_context(
119 service: &SagentService,
120 caller: Uuid,
121 space_name: &str,
122 collection_id: Uuid,
123) -> Result<(spaces::Model, agents::Model), SagentError> {
124 let space = space_context(service, caller, space_name).await?;
125 let collection = lookup_collection(service, &space, collection_id, caller).await?;
126 Ok((space, collection))
127}
128
129pub async fn task_context(
130 service: &SagentService,
131 caller: Uuid,
132 space_name: &str,
133 collection_id: Uuid,
134 task_ref: &str,
135) -> Result<(spaces::Model, agents::Model, agent::Model), SagentError> {
136 let (space, collection) =
137 collection_context(service, caller, space_name, collection_id).await?;
138 let task = lookup_task_by_ref(service, &collection, task_ref).await?;
139 Ok((space, collection, task))
140}
Deleted lib/sagent/src/share/mod.rs lib/sagent/src/share/mod.rs +0 −3
@@ -1,3 +0,0 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
Deleted lib/sagent/src/errors.rs lib/sagent/src/errors.rs +0 −74
@@ -1,74 +0,0 @@
1use std::fmt;
2
3#[derive(Debug)]
4pub enum SagentError {
5 BadRequest(String),
6 Unauthorized,
7 Forbidden,
8 NotFound,
9 Conflict(String),
10 RateLimited(String),
11 Upstream(String),
12 Unavailable(String),
13 Internal(String),
14}
15
16impl fmt::Display for SagentError {
17 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18 match self {
19 Self::BadRequest(message) => write!(f, "{message}"),
20 Self::Unauthorized => write!(f, "unauthorized"),
21 Self::Forbidden => write!(f, "forbidden"),
22 Self::NotFound => write!(f, "not found"),
23 Self::Conflict(message) => write!(f, "{message}"),
24 Self::RateLimited(message) => write!(f, "{message}"),
25 Self::Upstream(message) => write!(f, "upstream rejected the request: {message}"),
26 Self::Unavailable(message) => write!(f, "agent gateway unavailable: {message}"),
27 Self::Internal(message) => write!(f, "{message}"),
28 }
29 }
30}
31
32impl std::error::Error for SagentError {}
33
34export::db_error_conversions!(SagentError);
35export::export_error_conversion!(SagentError);
36export::json_payload_conversion!(SagentError);
37export::domain_error_passthrough!(SagentError from space::errors::SpaceError);
38
39export::api_error! {
40 log: "sagent",
41 pub enum SagentApiError {
42 BadRequest(400, "request failed validation"),
43 Unauthorized(401, "authentication required"),
44 Forbidden(403, "operation not allowed"),
45 NotFound(404, "resource not found"),
46 Conflict(409, "resource conflict"),
47 TooManyRequests(429, "rate limit exceeded"),
48 Internal(500, "internal server error"),
49 BadGateway(502, "agent gateway rejected the request"),
50 ServiceUnavailable(503, "agent gateway unavailable"),
51 }
52}
53
54impl From<SagentError> for SagentApiError {
55 fn from(error: SagentError) -> Self {
56 match error {
57 SagentError::BadRequest(msg) => Self::bad_request(msg),
58 SagentError::Unauthorized => Self::unauthorized(),
59 SagentError::Forbidden => Self::forbidden(),
60 SagentError::NotFound => Self::not_found("sagent resource not found"),
61 SagentError::Conflict(msg) => Self::conflict(msg),
62 SagentError::RateLimited(msg) => Self::rate_limited(msg),
63 SagentError::Upstream(msg) => Self::build(
64 502,
65 export::rest::AppResponseData::err(502, msg).with_error_code("gateway_rejected"),
66 ),
67 SagentError::Unavailable(msg) => Self::build(
68 503,
69 export::rest::AppResponseData::err(503, msg).with_error_code("gateway_unavailable"),
70 ),
71 SagentError::Internal(message) => Self::internal(message),
72 }
73 }
74}
Deleted lib/sagent/src/lib.rs lib/sagent/src/lib.rs +0 −7
@@ -1,7 +0,0 @@
1pub mod errors;
2pub mod gateway;
3pub mod handlers;
4pub mod openapi;
5pub mod routes;
6pub mod services;
7pub mod share;
Deleted lib/sagent/src/openapi.rs lib/sagent/src/openapi.rs +0 −120
@@ -1,120 +0,0 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
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 crate::handlers::tasks::create_task::create_task,
13 crate::handlers::tasks::list_tasks::list_tasks,
14 crate::handlers::tasks::get_task::get_task,
15 crate::handlers::tasks::update_task::update_task,
16 crate::handlers::tasks::delete_task::delete_task,
17 crate::handlers::tasks::start_task::start_task,
18 crate::handlers::tasks::cancel_task::cancel_task,
19 crate::handlers::tasks::close_task::close_task,
20 crate::handlers::tasks::reopen_task::reopen_task,
21 crate::handlers::messages::list_messages::list_messages,
22 crate::handlers::labels::create_label::create_label,
23 crate::handlers::labels::list_labels::list_labels,
24 crate::handlers::labels::get_label::get_label,
25 crate::handlers::labels::update_label::update_label,
26 crate::handlers::labels::delete_label::delete_label,
27 crate::handlers::labels::list_task_labels::list_task_labels,
28 crate::handlers::labels::bind_label::bind_label,
29 crate::handlers::labels::unbind_label::unbind_label,
30 crate::handlers::members::create_member::create_member,
31 crate::handlers::members::list_members::list_members,
32 crate::handlers::members::delete_member::delete_member,
33 crate::handlers::reads::get_read_state::get_read_state,
34 crate::handlers::reads::update_read_state::update_read_state,
35 crate::handlers::repo_relations::create_repo_relation::create_repo_relation,
36 crate::handlers::repo_relations::list_repo_relations::list_repo_relations,
37 crate::handlers::repo_relations::delete_repo_relation::delete_repo_relation,
38 crate::handlers::artifacts::list_artifacts::list_artifacts,
39 crate::handlers::artifacts::get_artifact::get_artifact,
40 crate::handlers::events::list_events::list_events,
41 crate::handlers::tokens::get_task_token::get_task_token,
42 crate::handlers::watches::upsert_watch::upsert_watch,
43 ),
44 components(schemas(
45 AppResponseData<export::rest::NoData>,
46 AppResponseData<crate::services::collections::CollectionDto>,
47 AppResponseData<crate::services::collections::CollectionPage>,
48 AppResponseData<crate::services::tasks::AgentDto>,
49 AppResponseData<crate::services::tasks::AgentPage>,
50 export::rest::NoData,
51 space::services::spaces::SpaceNamePath,
52 crate::services::collections::CollectionDto,
53 crate::services::collections::CollectionPage,
54 crate::services::collections::CreateCollectionCmd,
55 crate::services::collections::UpdateCollectionCmd,
56 crate::services::collections::ListCollectionQuery,
57 crate::services::collections::CollectionPath,
58 crate::services::tasks::AgentDto,
59 crate::services::tasks::AgentPage,
60 crate::services::tasks::CreateAgentCmd,
61 crate::services::tasks::UpdateAgentCmd,
62 crate::services::tasks::ListAgentQuery,
63 crate::services::tasks::CollectionRef,
64 crate::services::tasks::AgentPath,
65 models::sagent::agent::AgentState,
66 models::sagent::agent::AgentPriority,
67 AppResponseData<crate::services::messages::AgentMessagePage>,
68 AppResponseData<crate::services::events::AgentEventPage>,
69 AppResponseData<crate::services::watches::AgentWatchDto>,
70 AppResponseData<crate::services::tokens::TaskTokenDto>,
71 crate::services::messages::AgentMessageDto,
72 crate::services::messages::AgentMessagePage,
73 crate::services::messages::ListMessageQuery,
74 crate::services::events::AgentEventDto,
75 crate::services::events::AgentEventPage,
76 crate::services::events::ListAgentEventQuery,
77 crate::services::watches::AgentWatchDto,
78 crate::services::watches::UpsertWatchCmd,
79 crate::services::tokens::TaskTokenDto,
80 models::sagent::agent_message::AgentRole,
81 AppResponseData<crate::services::labels::AgentLabelDto>,
82 AppResponseData<crate::services::labels::AgentLabelPage>,
83 AppResponseData<Vec<crate::services::labels::AgentLabelDto>>,
84 crate::services::labels::AgentLabelDto,
85 crate::services::labels::AgentLabelPage,
86 crate::services::labels::CreateAgentLabelCmd,
87 crate::services::labels::UpdateAgentLabelCmd,
88 crate::services::labels::ListAgentLabelQuery,
89 crate::services::labels::AgentLabelPath,
90 crate::services::labels::TaskLabelPath,
91 AppResponseData<crate::services::members::AgentMemberDto>,
92 AppResponseData<crate::services::members::AgentMemberPage>,
93 AppResponseData<crate::services::reads::AgentReadStateDto>,
94 AppResponseData<crate::services::repo_relations::AgentRepoRelDto>,
95 AppResponseData<Vec<crate::services::repo_relations::AgentRepoRelDto>>,
96 AppResponseData<crate::services::artifacts::AgentArtifactDto>,
97 AppResponseData<crate::services::artifacts::AgentArtifactPage>,
98 crate::services::members::AgentMemberDto,
99 crate::services::members::AgentMemberPage,
100 crate::services::members::CreateAgentMemberCmd,
101 crate::services::members::ListAgentMemberQuery,
102 crate::services::members::TaskMemberPath,
103 crate::services::reads::AgentReadStateDto,
104 crate::services::reads::UpdateReadStateCmd,
105 crate::services::repo_relations::AgentRepoRelDto,
106 crate::services::repo_relations::CreateAgentRepoRelCmd,
107 crate::services::repo_relations::TaskRepoRelPath,
108 crate::services::artifacts::AgentArtifactDto,
109 crate::services::artifacts::AgentArtifactPage,
110 crate::services::artifacts::ListAgentArtifactQuery,
111 crate::services::artifacts::TaskArtifactPath,
112 models::sagent::agent_member::AgentMemberKind,
113 models::sagent::agent_event_line::AgentEventType,
114 models::common::watch_mode::WatchMode,
115 )),
116 tags(
117 (name = "sagent", description = "Sandbox agent API")
118 )
119)]
120pub struct SagentsApiDoc;
Deleted lib/sagent/src/routes.rs lib/sagent/src/routes.rs +0 −143
@@ -1,143 +0,0 @@
1use axum::Router;
2use axum::routing::{delete, get, post, put};
3
4use crate::handlers::artifacts::get_artifact::get_artifact;
5use crate::handlers::artifacts::list_artifacts::list_artifacts;
6use crate::handlers::collections::create_collection::create_collection;
7use crate::handlers::collections::delete_collection::delete_collection;
8use crate::handlers::collections::get_collection::get_collection;
9use crate::handlers::collections::list_collections::list_collections;
10use crate::handlers::collections::update_collection::update_collection;
11use crate::handlers::events::list_events::list_events;
12use crate::handlers::labels::bind_label::bind_label;
13use crate::handlers::labels::create_label::create_label;
14use crate::handlers::labels::delete_label::delete_label;
15use crate::handlers::labels::get_label::get_label;
16use crate::handlers::labels::list_labels::list_labels;
17use crate::handlers::labels::list_task_labels::list_task_labels;
18use crate::handlers::labels::unbind_label::unbind_label;
19use crate::handlers::labels::update_label::update_label;
20use crate::handlers::members::create_member::create_member;
21use crate::handlers::members::delete_member::delete_member;
22use crate::handlers::members::list_members::list_members;
23use crate::handlers::messages::list_messages::list_messages;
24use crate::handlers::reads::get_read_state::get_read_state;
25use crate::handlers::reads::update_read_state::update_read_state;
26use crate::handlers::repo_relations::create_repo_relation::create_repo_relation;
27use crate::handlers::repo_relations::delete_repo_relation::delete_repo_relation;
28use crate::handlers::repo_relations::list_repo_relations::list_repo_relations;
29use crate::handlers::tasks::cancel_task::cancel_task;
30use crate::handlers::tasks::close_task::close_task;
31use crate::handlers::tasks::create_task::create_task;
32use crate::handlers::tasks::delete_task::delete_task;
33use crate::handlers::tasks::get_task::get_task;
34use crate::handlers::tasks::list_tasks::list_tasks;
35use crate::handlers::tasks::reopen_task::reopen_task;
36use crate::handlers::tasks::start_task::start_task;
37use crate::handlers::tasks::update_task::update_task;
38use crate::handlers::tokens::get_task_token::get_task_token;
39use crate::handlers::watches::upsert_watch::upsert_watch;
40use crate::services::service::SagentService;
41use crate::share::guard::sagent_guard;
42
43pub fn router(service: SagentService) -> Router<SagentService> {
44 Router::new()
45 .route(
46 "/agents/{space_name}/collections",
47 post(create_collection).get(list_collections),
48 )
49 .route(
50 "/agents/{space_name}/collections/{collection_id}",
51 get(get_collection)
52 .patch(update_collection)
53 .delete(delete_collection),
54 )
55 .route(
56 "/agents/{space_name}/collections/{collection_id}/tasks",
57 post(create_task).get(list_tasks),
58 )
59 .route(
60 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}",
61 get(get_task).patch(update_task).delete(delete_task),
62 )
63 .route(
64 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/start",
65 post(start_task),
66 )
67 .route(
68 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/cancel",
69 post(cancel_task),
70 )
71 .route(
72 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/close",
73 post(close_task),
74 )
75 .route(
76 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/reopen",
77 post(reopen_task),
78 )
79 .route(
80 "/agents/{space_name}/collections/{collection_id}/labels",
81 post(create_label).get(list_labels),
82 )
83 .route(
84 "/agents/{space_name}/collections/{collection_id}/labels/{label_id}",
85 get(get_label).patch(update_label).delete(delete_label),
86 )
87 .route(
88 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels",
89 get(list_task_labels),
90 )
91 .route(
92 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels/{label_id}",
93 post(bind_label).delete(unbind_label),
94 )
95 .route(
96 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/messages",
97 get(list_messages),
98 )
99 .route(
100 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members",
101 post(create_member).get(list_members),
102 )
103 .route(
104 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members/{user_id}",
105 delete(delete_member),
106 )
107 .route(
108 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/read-state",
109 get(get_read_state).put(update_read_state),
110 )
111 .route(
112 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos",
113 post(create_repo_relation).get(list_repo_relations),
114 )
115 .route(
116 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos/{rel_id}",
117 delete(delete_repo_relation),
118 )
119 .route(
120 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts",
121 get(list_artifacts),
122 )
123 .route(
124 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts/{artifact_id}",
125 get(get_artifact),
126 )
127 .route(
128 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/events",
129 get(list_events),
130 )
131 .route(
132 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/token",
133 get(get_task_token),
134 )
135 .route(
136 "/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/watch",
137 put(upsert_watch),
138 )
139 .route_layer(axum::middleware::from_fn_with_state(
140 service.clone(),
141 sagent_guard,
142 ))
143}
Deleted lib/sagent/Cargo.toml lib/sagent/Cargo.toml +0 −25
@@ -1,25 +0,0 @@
1[package]
2name = "sagent"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7agent-sdk = { workspace = true }
8agent-types = { workspace = true }
9async-trait = { workspace = true }
10axum = { workspace = true }
11config = { workspace = true }
12export = { workspace = true }
13futures = { workspace = true }
14models = { workspace = true }
15sea-orm = { workspace = true }
16serde = { workspace = true, features = ["derive"] }
17serde_json = { workspace = true }
18session = { workspace = true }
19smol_str = { workspace = true }
20space = { workspace = true }
21time = { workspace = true }
22tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
23tracing = { workspace = true }
24utoipa = { workspace = true }
25uuid = { workspace = true }
No newline at end of file
Modified lib/webapi/src/lib.rs lib/webapi/src/lib.rs +1 −3 View lib/webapi/src/lib.rs
@@ -60,7 +60,6 @@
6060 ::search::SearchService::from_parts(state.database.clone(), state.config.search.clone())
6161 .expect("validated search client configuration");
6262 let import = ::import::ImportService::new(state.clone());
63 let sagent = sagent::services::service::SagentService::new(state.clone());
6463 let health_state = state;
6564
6665 let public: Router = Router::new()
@@ -73,8 +72,7 @@
7372 .merge(pulls::routes::router().with_state(pulls))
7473 .merge(crate::search::router().with_state(search.clone()))
7574 .merge(::import::routes::router(import.clone()).with_state(import.clone()))
76 .merge(ci::routes::router().with_state(ci.clone()))
77 .merge(sagent::routes::router(sagent.clone()).with_state(sagent));
75 .merge(ci::routes::router().with_state(ci.clone()));
7876 let internal: Router = Router::new()
7977 .merge(git::routes::internal_router(git.clone()).with_state(git))
8078 .merge(crate::search::internal_router().with_state(search))
Modified lib/webapi/src/openapi.rs lib/webapi/src/openapi.rs +0 −1 View lib/webapi/src/openapi.rs
@@ -114,7 +114,6 @@
114114 m!("search", crate::search::SearchApiDoc::openapi());
115115 m!("import", import::openapi::ImportApiDoc::openapi());
116116 m!("ci", ci::openapi::PipelinesApiDoc::openapi());
117 m!("sagent", sagent::openapi::SagentsApiDoc::openapi());
118117 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
119118 doc.info.description = Some(CONVENTIONS.to_string());
120119 doc.info.license = None;
Modified lib/webapi/Cargo.toml lib/webapi/Cargo.toml +0 −1 View lib/webapi/Cargo.toml
@@ -8,7 +8,6 @@
88auth = { workspace = true }
99chat = { workspace = true }
1010issues = { workspace = true }
11sagent = { workspace = true }
1211pulls = { workspace = true }
1312space = { workspace = true }
1413user = { workspace = true }
Modified web/scripts/lint-i18n.mjs web/scripts/lint-i18n.mjs +0 −1 View web/scripts/lint-i18n.mjs
@@ -34,7 +34,6 @@
3434/** Files whose CJK text is authored content, not translatable copy. */
3535const CJK_EXEMPT = [
3636 'src/shared/api/', // generated client; descriptions come from the OpenAPI doc
37 'src/shared/sagent/', // vendored upstream agent SDK (agent/sdk/ts-sdk), kept byte-identical
3837 'src/shared/i18n/messages/', // the dictionaries themselves
3938 'src/shared/i18n/locale.ts', // `localeLabel` is an endonym table: 简体中文 is
4039 // the correct label for Chinese even while the UI is in English
Modified web/src/app/routes/router.tsx web/src/app/routes/router.tsx +0 −2 View web/src/app/routes/router.tsx
@@ -38,12 +38,10 @@
3838 { path: '@me/notify' },
3939 { path: '@me/chat/:roomId?' },
4040 { path: '@me/board/:boardId?' },
41 { path: '@me/agent/:collectionId?' },
4241 { path: '@me/repo/:repoName?' },
4342 { path: ':spaceName' },
4443 { path: ':spaceName/chat/:roomId?' },
4544 { path: ':spaceName/board/:boardId?' },
46 { path: ':spaceName/agent/:collectionId?' },
4745 { path: ':spaceName/repo/:repoName?' },
4846 ],
4947 },
Deleted web/src/entities/agent/model/agent.ts web/src/entities/agent/model/agent.ts +0 −195
@@ -1,195 +0,0 @@
1/**
2 * Sandbox-agent (智能体) row shapes.
3 *
4 * Types are derived from the generated response envelopes wherever the backend
5 * exposes one, so a schema change in `bun run gen:api` shows up here as a type
6 * error instead of a runtime surprise. Paged payloads are typed as the generic
7 * `Page` schema by the generator, so their item rows are declared structurally
8 * (they mirror `AgentMessageDto` / `AgentEventDto` in `lib/sagent`), and the
9 * two enums the generator inlines into the DTOs are declared here.
10 *
11 * A 智能体任务 is addressable by uuid *or* collection-scoped number (`?task=12`),
12 * so every task id below accepts either form.
13 */
14import type {
15 AgentMemberKind,
16 AgentPriority,
17 AgentState,
18 AppResponseData_AgentArtifactDto,
19 AppResponseData_AgentDto,
20 AppResponseData_AgentLabelDto,
21 AppResponseData_AgentMemberDto,
22 AppResponseData_AgentReadStateDto,
23 AppResponseData_AgentRepoRelDto,
24 AppResponseData_AgentWatchDto,
25 AppResponseData_CollectionDto,
26 AppResponseData_TaskTokenDto,
27 UserDto,
28 WatchMode,
29} from '@/shared/api'
30
31export type {
32 AgentMemberKind,
33 AgentPriority,
34 AgentState,
35 AppResponseData_AgentDto,
36 AppResponseData_TaskTokenDto,
37 WatchMode,
38}
39
40/** Who wrote a mirrored message (`AgentRole` in `lib/models`). */
41export type AgentRole = 'user' | 'agent' | 'system' | 'tool'
42
43/** Activity kinds (`AgentEventType` in `lib/models`). */
44export type AgentEventType =
45 | 'created'
46 | 'title_updated'
47 | 'body_updated'
48 | 'started'
49 | 'state_changed'
50 | 'message_added'
51 | 'run_event'
52 | 'run_failed'
53 | 'artifact_added'
54 | 'assigned'
55 | 'unassigned'
56 | 'label_added'
57 | 'label_removed'
58 | 'repo_relation_added'
59 | 'repo_relation_removed'
60 | 'subscriber_lost'
61 | 'closed'
62 | 'reopened'
63
64/** One agent collection, i.e. the `dir` node's payload (`GET /agents/{space}/collections`). */
65export type AgentCollection = NonNullable<AppResponseData_CollectionDto['data']>
66
67/** Full task payload (`GET …/tasks/{task_id}`). */
68export type AgentTask = NonNullable<AppResponseData_AgentDto['data']>
69
70/** A watch row for the signed-in user (`GET/PUT …/tasks/{task_id}/watch`). */
71export type AgentWatch = NonNullable<AppResponseData_AgentWatchDto['data']>
72
73/** Session credentials for the gateway's session surface (`GET …/token`). */
74export type TaskToken = NonNullable<AppResponseData_TaskTokenDto['data']>
75
76/** Row of `GET …/tasks/{task_id}/messages` — the local mirror of the session transcript. */
77export interface AgentMessage {
78 id: string
79 agent_id: string
80 seq: number
81 gateway_message_id?: string | null
82 author_id?: string | null
83 role: AgentRole
84 content: string
85 created_at: string
86}
87
88/** Row of `GET …/tasks/{task_id}/events`. */
89export interface AgentEvent {
90 id: string
91 agent_id?: string | null
92 collection_id?: string | null
93 user_id?: string | null
94 event_type: AgentEventType | string
95 run_id?: string | null
96 content?: unknown
97 created_at: string
98}
99
100/** The generic `Page` schema, narrowed to one row type. */
101export interface AgentPage<T> {
102 items: T[]
103 total: number
104 limit: number
105 offset: number
106}
107
108/** `all` is a UI-only choice: omitting the key is what "no state filter" means. */
109export type AgentStateFilter = AgentState | 'all'
110
111export type AgentSort = 'created' | 'updated' | 'priority' | 'number'
112
113/** Server-side filters of `GET …/tasks`. */
114export interface AgentQuery {
115 q?: string
116 state?: AgentStateFilter
117 priority?: AgentPriority | ''
118 sort?: AgentSort
119 limit?: number
120 offset?: number
121}
122
123export interface NewAgentCmd {
124 title: string
125 description?: string
126 body?: string
127 priority?: AgentPriority
128 due_at?: string | null
129}
130
131export type AgentPatchCmd = {
132 title?: string
133 description?: string | null
134 body?: string | null
135 priority?: AgentPriority
136 due_at?: string | null
137}
138
139export interface NewCollectionCmd {
140 name: string
141 description?: string
142 icon?: string
143 parent_dir_id?: string
144}
145
146/* ------------------------------------------------------------ collaboration */
147
148/** A collection-scoped label (`GET …/collections/{cid}/labels`). */
149export type AgentLabel = NonNullable<AppResponseData_AgentLabelDto['data']>
150
151/** One task membership row, `kind` telling the role apart. */
152export type AgentMember = NonNullable<AppResponseData_AgentMemberDto['data']>
153
154/** A sandbox artifact with its presigned download link (`GET …/artifacts`). */
155export type AgentArtifact = NonNullable<AppResponseData_AgentArtifactDto['data']>
156
157/** Repo a task may check out, optionally pinned to a ref / sub path. */
158export type AgentRepoRel = NonNullable<AppResponseData_AgentRepoRelDto['data']>
159
160/** Per-user read cursor of one task; `unread` is derived, never stored. */
161export type AgentReadState = NonNullable<AppResponseData_AgentReadStateDto['data']>
162
163/** Space member row used by the assignee picker. */
164export interface AgentUserRow {
165 user?: UserDto | null
166 alias_name?: string | null
167}
168
169export interface NewAgentLabelCmd {
170 name: string
171 color: string
172 description?: string
173 exclusive?: boolean
174 exclusive_order?: number
175}
176
177export type AgentLabelPatchCmd = {
178 name?: string
179 color?: string
180 description?: string | null
181 exclusive?: boolean
182 exclusive_order?: number
183 is_archived?: boolean
184}
185
186export interface NewAgentMemberCmd {
187 user_id: string
188 kind?: AgentMemberKind
189}
190
191export interface NewAgentRepoRelCmd {
192 repo_id: string
193 ref_name?: string
194 sub_path?: string
195}
No newline at end of file
Deleted web/src/entities/agent/model/api.ts web/src/entities/agent/model/api.ts +0 −400
@@ -1,400 +0,0 @@
1/**
2 * Sandbox-agent transport: every call the 智能体 pages make, wrapped in the
3 * shared envelope contract so the UI only ever sees unwrapped rows or an
4 * `ApiFailure`.
5 *
6 * The generated client types paged endpoints against the generic `Page` schema
7 * (one shape for every list), so the row type is asserted here — the single
8 * place in the app allowed to do that, which keeps the casts out of the
9 * components and makes a schema drift show up as one localized error.
10 *
11 * Tasks are addressable by uuid *or* collection-scoped number (`?task=12`), so
12 * every `taskId` parameter below accepts either form.
13 */
14import Apis from '@/shared/api'
15import { unwrapApi, type ApiEnvelope } from '@/shared/lib'
16import type {
17 AgentArtifact,
18 AgentCollection,
19 AgentEvent,
20 AgentLabel,
21 AgentLabelPatchCmd,
22 AgentMember,
23 AgentMemberKind,
24 AgentMessage,
25 AgentPage,
26 AgentPatchCmd,
27 AgentQuery,
28 AgentReadState,
29 AgentRepoRel,
30 AgentTask,
31 AgentUserRow,
32 AgentWatch,
33 NewAgentCmd,
34 NewAgentLabelCmd,
35 NewAgentMemberCmd,
36 NewAgentRepoRelCmd,
37 NewCollectionCmd,
38 TaskToken,
39 WatchMode,
40} from './agent'
41import { SORT_PARAMS } from './meta'
42
43/**
44 * Empty strings are dropped, not sent: Alova omits `undefined` query values but
45 * forwards `''`, and the backend's `Option<Uuid>` / `Option<AgentState>`
46 * deserialization rejects an empty `dir_id=` / `state=` with a 400.
47 */
48const nz = <T,>(value: T | '' | null | undefined): T | undefined => (value ? value : undefined)
49
50/** Collection id path params repeated by almost every agent endpoint. */
51export interface AgentRef {
52 space: string
53 collection: string
54}
55
56const pageOf = <T,>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<AgentPage<T>> =>
57 unwrapApi<AgentPage<T> | null>(method as PromiseLike<ApiEnvelope<AgentPage<T> | null>>).then(p => ({
58 items: p?.items ?? [],
59 total: p?.total ?? 0,
60 limit: p?.limit ?? 0,
61 offset: p?.offset ?? 0,
62 }))
63
64const oneOf = <T,>(method: PromiseLike<ApiEnvelope<unknown>>): Promise<T> =>
65 unwrapApi<T>(method as PromiseLike<ApiEnvelope<T>>)
66
67/* ------------------------------------------------------------- collection */
68
69/** Every collection of one space, page by page (`GET /agents/{space}/collections`). */
70export const listCollections = (space: string, query: { q?: string; dirId?: string; limit?: number; offset?: number } = {}) =>
71 pageOf<AgentCollection>(
72 Apis.sagent.list_collections({
73 pathParams: { space_name: space },
74 params: { q: nz(query.q), dir_id: nz(query.dirId), limit: query.limit, offset: query.offset ?? 0 },
75 }),
76 )
77
78export const getCollection = (space: string, collectionId: string) =>
79 unwrapApi<AgentCollection>(
80 Apis.sagent.get_collection({ pathParams: { space_name: space, collection_id: collectionId } }),
81 )
82
83export const createCollection = (space: string, cmd: NewCollectionCmd) =>
84 oneOf<AgentCollection>(
85 Apis.sagent.create_collection({
86 pathParams: { space_name: space },
87 data: {
88 name: cmd.name,
89 parent_dir_id: cmd.parent_dir_id ?? null,
90 icon: cmd.icon ?? null,
91 description: cmd.description ?? null,
92 },
93 }),
94 )
95
96export const updateCollection = (
97 space: string,
98 collectionId: string,
99 cmd: { name?: string; icon?: string | null; description?: string | null },
100) =>
101 oneOf<AgentCollection>(
102 Apis.sagent.update_collection({
103 pathParams: { space_name: space, collection_id: collectionId },
104 data: cmd,
105 }),
106 )
107
108export const deleteCollection = (space: string, collectionId: string) =>
109 unwrapApi<null>(
110 Apis.sagent.delete_collection({ pathParams: { space_name: space, collection_id: collectionId } }),
111 )
112
113/* ------------------------------------------------------------------- task */
114
115/** One page of tasks with the console's filters applied server-side. */
116export const listTasks = ({ space, collection }: AgentRef, query: AgentQuery = {}) =>
117 pageOf<AgentTask>(
118 Apis.sagent.list_tasks({
119 pathParams: { space_name: space, collection_id: collection },
120 params: {
121 // `all` is a UI-only choice: the backend filters one concrete state, and
122 // omitting the key at all is what "no state filter" means.
123 state: query.state && query.state !== 'all' ? query.state : undefined,
124 q: nz(query.q),
125 priority: nz(query.priority),
126 sort: query.sort ? SORT_PARAMS[query.sort] : undefined,
127 limit: query.limit,
128 offset: query.offset ?? 0,
129 },
130 }),
131 )
132
133export const getTask = ({ space, collection }: AgentRef, taskId: string) =>
134 unwrapApi<AgentTask>(
135 Apis.sagent.get_task({
136 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
137 }),
138 )
139
140export const createTask = ({ space, collection }: AgentRef, cmd: NewAgentCmd) =>
141 oneOf<AgentTask>(
142 Apis.sagent.create_task({
143 pathParams: { space_name: space, collection_id: collection },
144 data: {
145 title: cmd.title,
146 description: cmd.description ?? null,
147 body: cmd.body ?? '',
148 priority: cmd.priority ?? 'none',
149 due_at: cmd.due_at ?? null,
150 },
151 }),
152 )
153
154export const updateTask = ({ space, collection }: AgentRef, taskId: string, cmd: AgentPatchCmd) =>
155 oneOf<AgentTask>(
156 Apis.sagent.update_task({
157 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
158 data: cmd,
159 }),
160 )
161
162export const deleteTask = ({ space, collection }: AgentRef, taskId: string) =>
163 unwrapApi<null>(
164 Apis.sagent.delete_task({
165 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
166 }),
167 )
168
169/**
170 * Hands the task brief to the sandbox agent: creates (or reuses) the gateway
171 * session, sends the brief as the first message and flips the task to
172 * `pending`. 409 means the task already ran or is closed.
173 */
174export const startTask = ({ space, collection }: AgentRef, taskId: string) =>
175 oneOf<AgentTask>(
176 Apis.sagent.start_task({
177 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
178 }),
179 )
180
181export const cancelTask = ({ space, collection }: AgentRef, taskId: string) =>
182 oneOf<AgentTask>(
183 Apis.sagent.cancel_task({
184 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
185 }),
186 )
187
188export const closeTask = ({ space, collection }: AgentRef, taskId: string) =>
189 oneOf<AgentTask>(
190 Apis.sagent.close_task({
191 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
192 }),
193 )
194
195export const reopenTask = ({ space, collection }: AgentRef, taskId: string) =>
196 oneOf<AgentTask>(
197 Apis.sagent.reopen_task({
198 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
199 }),
200 )
201
202/* --------------------------------------------------------- transcript */
203
204/** The mirrored transcript, oldest first; `afterSeq` resumes from a watermark. */
205export const listMessages = ({ space, collection }: AgentRef, taskId: string, afterSeq?: number, limit = 100) =>
206 pageOf<AgentMessage>(
207 Apis.sagent.list_messages({
208 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
209 params: { after_seq: afterSeq, limit },
210 }),
211 )
212
213export const listEvents = ({ space, collection }: AgentRef, taskId: string, limit = 50) =>
214 pageOf<AgentEvent>(
215 Apis.sagent.list_events({
216 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
217 params: { limit },
218 }),
219 )
220
221/** Watch state is per user; the backend upserts, so this is idempotent. */
222export const setTaskWatch = ({ space, collection }: AgentRef, taskId: string, mode: WatchMode) =>
223 unwrapApi<AgentWatch>(
224 Apis.sagent.upsert_watch({
225 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
226 data: { mode },
227 }),
228 ).catch(() => null)
229
230/**
231 * Session credentials for the gateway's session surface: the browser talks to
232 * `base_url` directly with `token` (see `shared/sagent`). Issuing requires the
233 * task to have been started at least once.
234 */
235export const getTaskToken = ({ space, collection }: AgentRef, taskId: string) =>
236 unwrapApi<TaskToken>(
237 Apis.sagent.get_task_token({
238 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
239 }),
240 )
241
242/* ----------------------------------------------------------------- labels */
243
244/** Collection-scoped labels; every label lives on the collection's `dir` node. */
245export const listLabels = ({ space, collection }: AgentRef, query: { q?: string; isArchived?: boolean } = {}) =>
246 pageOf<AgentLabel>(
247 Apis.sagent.list_labels({
248 pathParams: { space_name: space, collection_id: collection },
249 params: { q: nz(query.q), is_archived: query.isArchived, limit: 100, offset: 0 },
250 }),
251 )
252
253export const createLabel = ({ space, collection }: AgentRef, cmd: NewAgentLabelCmd) =>
254 oneOf<AgentLabel>(
255 Apis.sagent.create_label({
256 pathParams: { space_name: space, collection_id: collection },
257 data: {
258 name: cmd.name,
259 color: cmd.color,
260 description: cmd.description ?? null,
261 exclusive: cmd.exclusive ?? null,
262 exclusive_order: cmd.exclusive_order ?? null,
263 },
264 }),
265 )
266
267export const updateLabel = ({ space, collection }: AgentRef, labelId: string, cmd: AgentLabelPatchCmd) =>
268 oneOf<AgentLabel>(
269 Apis.sagent.update_label({
270 pathParams: { space_name: space, collection_id: collection, label_id: labelId },
271 data: cmd,
272 }),
273 )
274
275export const deleteLabel = ({ space, collection }: AgentRef, labelId: string) =>
276 unwrapApi<null>(
277 Apis.sagent.delete_label({
278 pathParams: { space_name: space, collection_id: collection, label_id: labelId },
279 }),
280 )
281
282/** Labels bound to one task; the response is a bare array, not a page. */
283export const listTaskLabels = ({ space, collection }: AgentRef, taskId: string) =>
284 oneOf<AgentLabel[]>(
285 Apis.sagent.list_task_labels({
286 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
287 }),
288 ).then(labels => labels ?? [])
289
290export const bindTaskLabel = ({ space, collection }: AgentRef, taskId: string, labelId: string) =>
291 unwrapApi<null>(
292 Apis.sagent.bind_label({
293 pathParams: { space_name: space, collection_id: collection, task_id: taskId, label_id: labelId },
294 }),
295 )
296
297export const unbindTaskLabel = ({ space, collection }: AgentRef, taskId: string, labelId: string) =>
298 unwrapApi<null>(
299 Apis.sagent.unbind_label({
300 pathParams: { space_name: space, collection_id: collection, task_id: taskId, label_id: labelId },
301 }),
302 )
303
304/* ---------------------------------------------------------------- members */
305
306export const listMembers = ({ space, collection }: AgentRef, taskId: string, kind?: AgentMemberKind) =>
307 pageOf<AgentMember>(
308 Apis.sagent.list_members({
309 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
310 params: { kind, limit: 100, offset: 0 },
311 }),
312 )
313
314export const addMember = ({ space, collection }: AgentRef, taskId: string, cmd: NewAgentMemberCmd) =>
315 oneOf<AgentMember>(
316 Apis.sagent.create_member({
317 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
318 data: { user_id: cmd.user_id, kind: cmd.kind ?? null },
319 }),
320 )
321
322export const removeMember = ({ space, collection }: AgentRef, taskId: string, userId: string) =>
323 unwrapApi<null>(
324 Apis.sagent.delete_member({
325 pathParams: { space_name: space, collection_id: collection, task_id: taskId, user_id: userId },
326 }),
327 )
328
329/** Space members for the assignee picker; the agent domain only needs identity. */
330export const listSpaceMembers = (space: string) =>
331 pageOf<AgentUserRow>(
332 Apis.space.list_space_members({ pathParams: { space_name: space }, params: { limit: 100 } }),
333 )
334
335/* ------------------------------------------------------------------- repo */
336
337/** Repos a task may check out (`POST/GET …/tasks/{task_id}/repos`). */
338export const listRepoRelations = ({ space, collection }: AgentRef, taskId: string) =>
339 oneOf<AgentRepoRel[]>(
340 Apis.sagent.list_repo_relations({
341 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
342 }),
343 ).then(rows => rows ?? [])
344
345export const createRepoRelation = ({ space, collection }: AgentRef, taskId: string, cmd: NewAgentRepoRelCmd) =>
346 oneOf<AgentRepoRel>(
347 Apis.sagent.create_repo_relation({
348 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
349 data: { repo_id: cmd.repo_id, ref_name: cmd.ref_name ?? null, sub_path: cmd.sub_path ?? null },
350 }),
351 )
352
353export const deleteRepoRelation = ({ space, collection }: AgentRef, taskId: string, relId: string) =>
354 unwrapApi<null>(
355 Apis.sagent.delete_repo_relation({
356 pathParams: { space_name: space, collection_id: collection, task_id: taskId, rel_id: relId },
357 }),
358 )
359
360/* -------------------------------------------------------------- artifacts */
361
362/** Sandbox outputs, each carrying a freshly presigned download link. */
363export const listArtifacts = ({ space, collection }: AgentRef, taskId: string, query: { kind?: string } = {}) =>
364 pageOf<AgentArtifact>(
365 Apis.sagent.list_artifacts({
366 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
367 params: { kind: nz(query.kind), limit: 100, offset: 0 },
368 }),
369 )
370
371export const getArtifact = ({ space, collection }: AgentRef, taskId: string, artifactId: string) =>
372 oneOf<AgentArtifact>(
373 Apis.sagent.get_artifact({
374 pathParams: {
375 space_name: space,
376 collection_id: collection,
377 task_id: taskId,
378 artifact_id: artifactId,
379 },
380 }),
381 )
382
383/* ------------------------------------------------------------- read state */
384
385/** Read cursor of the signed-in user; `unread` is derived from the watermark. */
386export const getReadState = ({ space, collection }: AgentRef, taskId: string) =>
387 oneOf<AgentReadState>(
388 Apis.sagent.get_read_state({
389 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
390 }),
391 )
392
393/** Marks the task read; omitting the cursor means "up to the current watermark". */
394export const markTaskRead = ({ space, collection }: AgentRef, taskId: string, lastReadSeq?: number) =>
395 oneOf<AgentReadState>(
396 Apis.sagent.update_read_state({
397 pathParams: { space_name: space, collection_id: collection, task_id: taskId },
398 data: { last_read_seq: lastReadSeq ?? null },
399 }),
400 )
No newline at end of file
Deleted web/src/entities/agent/model/meta.ts web/src/entities/agent/model/meta.ts +0 −233
@@ -1,233 +0,0 @@
1/**
2 * Display metadata for agent rows: the labels, colours and ordering the console
3 * shows for values the API only names. Kept next to the domain (not in a
4 * component) because the cards, the filter chips, the list table and the detail
5 * panel all have to agree on them.
6 */
7import { isMessageKey, t } from '@/shared/i18n'
8import type {
9 AgentMemberKind,
10 AgentPriority,
11 AgentSort,
12 AgentState,
13 AgentStateFilter,
14 WatchMode,
15} from './agent'
16
17/** Creation entry points stay hidden until the console is verified in production. */
18export const AGENT_CREATE_ENABLED = false
19
20/** Backend enum order: an unfinished run reads left→right, then the outcomes. */
21export const AGENT_STATES: readonly AgentState[] = [
22 'draft',
23 'pending',
24 'planning',
25 'executing',
26 'waiting',
27 'succeeded',
28 'failed',
29 'canceled',
30 'closed',
31] as const
32
33/** States the archive subscriber still considers live (`ACTIVE_STATES` in `lib/sagent`). */
34export const AGENT_ACTIVE_STATES: readonly AgentState[] = ['pending', 'planning', 'executing', 'waiting'] as const
35
36export const AGENT_STATE_META: Readonly<Record<AgentState, { label: string; color: string }>> = {
37 draft: { get label() { return t('agent.state.draft') }, color: '#66717d' },
38 pending: { get label() { return t('agent.state.pending') }, color: '#8fb8ff' },
39 planning: { get label() { return t('agent.state.planning') }, color: '#8e4ec6' },
40 executing: { get label() { return t('agent.state.executing') }, color: '#00a2c7' },
41 waiting: { get label() { return t('agent.state.waiting') }, color: '#ffc53d' },
42 succeeded: { get label() { return t('agent.state.succeeded') }, color: '#3ba55d' },
43 failed: { get label() { return t('agent.state.failed') }, color: '#e5484d' },
44 canceled: { get label() { return t('agent.state.canceled') }, color: '#66717d' },
45 closed: { get label() { return t('agent.state.closed') }, color: '#66717d' },
46}
47
48export function stateOf(value: string | null | undefined): { label: string; color: string } {
49 return AGENT_STATE_META[value as AgentState] ?? { get label() { return t('agent.event.unknown') }, color: '#66717d' }
50}
51
52export function isActiveState(value: string | null | undefined): boolean {
53 return AGENT_ACTIVE_STATES.includes(value as AgentState)
54}
55
56export const STATE_FILTERS: ReadonlyArray<{ id: AgentStateFilter; label: string }> = [
57 ...AGENT_STATES.map(state => ({
58 id: state as AgentStateFilter,
59 get label() {
60 return AGENT_STATE_META[state].label
61 },
62 })),
63 { id: 'all', get label() { return t('agent.state.all') } },
64]
65
66export interface PriorityMeta {
67 id: AgentPriority
68 label: string
69 /** Badge background. */
70 color: string
71 /** Badge text colour that stays readable on `color`. */
72 text: string
73 /** High → low, used for client-side ordering. */
74 rank: number
75}
76
77/** Highest priority first — matches `?sort=priority` (backend sorts desc). */
78export const PRIORITIES: readonly PriorityMeta[] = [
79 { id: 'urgent', get label() { return t('label.priority.urgent') }, color: '#e5484d', text: '#ffffff', rank: 4 },
80 { id: 'high', get label() { return t('label.priority.high') }, color: '#f76808', text: '#ffffff', rank: 3 },
81 { id: 'medium', get label() { return t('label.priority.medium') }, color: '#ffc53d', text: '#3d2a00', rank: 2 },
82 { id: 'low', get label() { return t('label.priority.low') }, color: '#8fb8ff', text: '#12315e', rank: 1 },
83 { id: 'none', get label() { return t('label.priority.none') }, color: '#d6d9e0', text: '#3f434b', rank: 0 },
84] as const
85
86export const PRIORITY_META: Record<AgentPriority, PriorityMeta> = PRIORITIES.reduce(
87 (acc, meta) => ({ ...acc, [meta.id]: meta }),
88 {} as Record<AgentPriority, PriorityMeta>,
89)
90
91export function priorityOf(value: string | null | undefined): PriorityMeta {
92 return PRIORITY_META[value as AgentPriority] ?? PRIORITY_META.none
93}
94
95export const SORTS: ReadonlyArray<{ id: AgentSort; label: string }> = [
96 { id: 'created', get label() { return t('agent.sort.created') } },
97 { id: 'updated', get label() { return t('agent.sort.updated') } },
98 { id: 'priority', get label() { return t('agent.sort.priority') } },
99 { id: 'number', get label() { return t('agent.sort.number') } },
100]
101
102export const WATCH_MODES: readonly WatchMode[] = ['watching', 'unwatched', 'ignore']
103
104/** Sort values the backend rejects are dropped so the query stays valid. */
105export const SORT_PARAMS: Record<AgentSort, string> = {
106 created: 'created',
107 updated: 'updated',
108 priority: 'priority',
109 number: 'number',
110}
111
112export const WATCH_LABELS: Readonly<Record<WatchMode, string>> = {
113 get watching() {
114 return t('agent.watch.watching')
115 },
116 get unwatched() {
117 return t('agent.watch.unwatched')
118 },
119 get ignore() {
120 return t('agent.watch.ignore')
121 },
122}
123
124/** Roles a user can hold on one task (`AgentMemberKind` in `lib/models`). */
125export const AGENT_MEMBER_KINDS: readonly AgentMemberKind[] = ['assignee', 'mentioned', 'participant']
126
127export const MEMBER_KIND_LABELS: Readonly<Record<AgentMemberKind, string>> = {
128 get assignee() {
129 return t('agent.member.assignee')
130 },
131 get mentioned() {
132 return t('agent.member.mentioned')
133 },
134 get participant() {
135 return t('agent.member.participant')
136 },
137}
138
139/** Artifact size in the largest unit that keeps the number readable. */
140export function artifactSize(bytes: number): string {
141 if (!Number.isFinite(bytes) || bytes < 1024) return `${bytes} B`
142 const units = ['KB', 'MB', 'GB', 'TB']
143 let value = bytes / 1024
144 let unit = 0
145 while (value >= 1024 && unit < units.length - 1) {
146 value /= 1024
147 unit += 1
148 }
149 return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
150}
151
152export const ROLE_LABELS: Readonly<Record<string, string>> = {
153 get user() {
154 return t('agent.role.user')
155 },
156 get agent() {
157 return t('agent.role.agent')
158 },
159 get system() {
160 return t('agent.role.system')
161 },
162 get tool() {
163 return t('agent.role.tool')
164 },
165}
166
167/**
168 * Rust's `time` crate renders offsets as `+00:00:00` (seconds in the offset),
169 * which `Date` cannot parse. Same fold as `shared/lib/format`.
170 */
171export function parseAgentDate(value: string | null | undefined): Date | null {
172 if (!value) return null
173 const date = new Date(value.replace(/([+-]\d{2}):(\d{2}):(\d{2})$/, '$1:$2'))
174 return Number.isNaN(date.getTime()) ? null : date
175}
176
177/** `MM-DD HH:mm` for the activity feed and the mirrored transcript. */
178export function shortTime(value: string | null | undefined): string {
179 const date = parseAgentDate(value)
180 if (!date) return ''
181 const pad = (n: number) => String(n).padStart(2, '0')
182 return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
183}
184
185/** `YYYY-MM-DD` for `<input type="date">`, from the backend's own timestamp. */
186export function dateInputValue(value: string | null | undefined): string {
187 const date = parseAgentDate(value)
188 if (!date) return ''
189 const pad = (n: number) => String(n).padStart(2, '0')
190 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
191}
192
193/** Send a date-only pick as UTC noon so it never shifts a day across timezones. */
194export function dateApiValue(input: string): string | null {
195 if (!input) return null
196 const [year, month, day] = input.split('-').map(Number)
197 if (!year || !month || !day) return null
198 return new Date(Date.UTC(year, month - 1, day, 12, 0, 0)).toISOString()
199}
200
201/**
202 * Activity line labels, keyed by the backend's `AgentEventType` snake_case
203 * values. Resolved lazily so the table follows the active locale; unknown types
204 * fall back to the raw value, so a new server-side event still renders.
205 */
206export const EVENT_LABELS: Readonly<Record<string, string>> = new Proxy(
207 {},
208 {
209 get: (_target, key: string) => {
210 const entry = `agent.event.${key}`
211 return isMessageKey(entry) ? t(entry) : undefined
212 },
213 },
214)
215
216export function eventLabel(type: string | null | undefined): string {
217 if (!type) return t('agent.event.unknown')
218 return EVENT_LABELS[type] ?? t('agent.event.fallback', { type })
219}
220
221/** One-line summary of an event's `content` blob, for the activity feed. */
222export function describeEventContent(content: unknown): string {
223 if (content === null || content === undefined) return ''
224 if (typeof content === 'string') return content
225 if (typeof content === 'object') {
226 const row = content as Record<string, unknown>
227 for (const key of ['state', 'title', 'name', 'message', 'kind', 'reason', 'repo_name', 'label']) {
228 const value = row[key]
229 if (typeof value === 'string' && value) return value
230 }
231 }
232 return ''
233}
No newline at end of file
Deleted web/src/entities/agent/index.ts web/src/entities/agent/index.ts +0 −8
@@ -1,8 +0,0 @@
1/**
2 * Public API of the entities/agent slice — sandbox-agent (智能体) domain data.
3 *
4 * Consumers import from '@/entities/agent', never from deeper files.
5 */
6export * from './model/agent'
7export * from './model/meta'
8export * from './model/api'
No newline at end of file
Deleted web/src/features/agent-console/model/prefetch.ts web/src/features/agent-console/model/prefetch.ts +0 −26
@@ -1,26 +0,0 @@
1import { agentKey, prefetch } from '@/shared/lib'
2import { getCollection, listTasks, type AgentQuery } from '@/entities/agent'
3import { AGENT_PAGE_SIZE } from './use-agent-console-data'
4
5/**
6 * The query a console mounts with (agent-location defaults: state `all`, sort
7 * `created`, page 1). Field order matters — `useAgentConsoleData` derives its
8 * cache key from `JSON.stringify(query)`, so this literal must produce the
9 * exact same string as the console view's `buildAgentQuery`.
10 */
11const DEFAULT_QUERY: AgentQuery = {
12 state: 'all',
13 q: undefined,
14 priority: undefined,
15 sort: 'created',
16 limit: AGENT_PAGE_SIZE,
17 offset: 0,
18}
19
20/** Hover prefetch from the sidebar row: the collection and its first page. */
21export function prefetchAgentConsole(space: string, collection: string): void {
22 if (!space || !collection) return
23 const ref = { space, collection }
24 void prefetch(agentKey(space, collection, 'collection'), () => getCollection(space, collection))
25 void prefetch(agentKey(space, collection, `tasks:${JSON.stringify(DEFAULT_QUERY)}`), () => listTasks(ref, DEFAULT_QUERY))
26}
No newline at end of file
Deleted web/src/features/agent-console/model/query.ts web/src/features/agent-console/model/query.ts +0 −52
@@ -1,52 +0,0 @@
1/**
2 * The console's view state, split from the URL encoding in
3 * `pages/workspace/model/agent-location.ts`: pages own the query string, the
4 * feature owns what the query string means so the view and the hover prefetch
5 * build byte-identical requests (the cache key is `JSON.stringify(query)`).
6 */
7import type { AgentPriority, AgentQuery, AgentSort, AgentStateFilter } from '@/entities/agent'
8import { AGENT_PAGE_SIZE } from './use-agent-console-data'
9
10/** Two layouts, same rows: `cards` for reading, `list` for scanning. */
11export type AgentLayout = 'cards' | 'list'
12
13export interface AgentConsoleView {
14 layout: AgentLayout
15 /** `all` is the UI's "no state filter" choice and never reaches the wire. */
16 state: AgentStateFilter
17 q: string
18 priority: AgentPriority | ''
19 sort: AgentSort
20 page: number
21 /** Selected task: uuid or collection-scoped number, empty when none is open. */
22 task: string
23}
24
25export const AGENT_VIEW_DEFAULTS: AgentConsoleView = {
26 layout: 'cards',
27 state: 'all',
28 q: '',
29 priority: '',
30 sort: 'created',
31 page: 1,
32 task: '',
33}
34
35export function normalizeAgentLayout(value: string | null | undefined): AgentLayout {
36 return value === 'list' ? 'list' : 'cards'
37}
38
39/**
40 * Field order is part of the contract: the cache key is the JSON of this
41 * object, so reordering the literal invalidates every stored page.
42 */
43export function buildAgentQuery(view: AgentConsoleView): AgentQuery {
44 return {
45 state: view.state,
46 q: view.q || undefined,
47 priority: view.priority || undefined,
48 sort: view.sort,
49 limit: AGENT_PAGE_SIZE,
50 offset: (view.page - 1) * AGENT_PAGE_SIZE,
51 }
52}
No newline at end of file
Deleted web/src/features/agent-console/model/use-agent-console-data.ts web/src/features/agent-console/model/use-agent-console-data.ts +0 −100
@@ -1,100 +0,0 @@
1/**
2 * Read side of the 智能体 console: one call per panel (collection, task page,
3 * one task, its mirrored transcript and its activity feed), each cached under
4 * an `agent:` key so switching collections or reopening the console paints from
5 * the local tier first.
6 *
7 * Nothing here writes: mutations live in `agent-view`, which owns the reload
8 * key that every fetch below is keyed on.
9 */
10import {
11 agentKey,
12 useAsync,
13 ApiFailure,
14 type AsyncState,
15} from '@/shared/lib'
16import {
17 getCollection,
18 getTask,
19 listEvents,
20 listMessages,
21 listTasks,
22 type AgentCollection,
23 type AgentEvent,
24 type AgentMessage,
25 type AgentQuery,
26 type AgentRef,
27 type AgentTask,
28} from '@/entities/agent'
29
30export const AGENT_PAGE_SIZE = 30
31export const AGENT_TASK_PAGE_SIZE = 30
32
33export interface AgentConsoleData {
34 collection?: AgentCollection
35 collectionFailure: ApiFailure | null
36 collectionLoading: boolean
37 tasks: AgentTask[]
38 total: number
39 tasksFailure: ApiFailure | null
40 tasksLoading: boolean
41 task?: AgentTask
42 taskFailure: ApiFailure | null
43 taskLoading: boolean
44 messages: AgentMessage[]
45 events: AgentEvent[]
46}
47
48const failureOf = (state: AsyncState<unknown>): ApiFailure | null =>
49 state.error instanceof ApiFailure ? state.error : null
50
51export function useAgentConsoleData(
52 ref: AgentRef,
53 query: AgentQuery,
54 taskId: string | null,
55 reloadKey: number,
56): AgentConsoleData {
57 const queryKey = JSON.stringify(query)
58 const { space, collection } = ref
59
60 const collectionState = useAsync(() => getCollection(space, collection), [space, collection, reloadKey], {
61 cacheKey: agentKey(space, collection, 'collection'),
62 })
63
64 const tasksState = useAsync(() => listTasks(ref, query), [space, collection, queryKey, reloadKey], {
65 cacheKey: agentKey(space, collection, `tasks:${queryKey}`),
66 })
67
68 const taskState = useAsync(
69 () => (taskId ? getTask(ref, taskId) : Promise.resolve(undefined)),
70 [space, collection, taskId, reloadKey],
71 { cacheKey: taskId ? agentKey(space, collection, `task:${taskId}`) : null },
72 )
73
74 const messagesState = useAsync(
75 () => (taskId ? listMessages(ref, taskId, undefined, 200) : Promise.resolve(null)),
76 [space, collection, taskId, reloadKey],
77 { cacheKey: taskId ? agentKey(space, collection, `messages:${taskId}`) : null },
78 )
79
80 const eventsState = useAsync(
81 () => (taskId ? listEvents(ref, taskId, 50) : Promise.resolve(null)),
82 [space, collection, taskId, reloadKey],
83 { cacheKey: taskId ? agentKey(space, collection, `events:${taskId}`) : null },
84 )
85
86 return {
87 collection: collectionState.data,
88 collectionFailure: failureOf(collectionState),
89 collectionLoading: collectionState.loading,
90 tasks: tasksState.data?.items ?? [],
91 total: tasksState.data?.total ?? 0,
92 tasksFailure: failureOf(tasksState),
93 tasksLoading: tasksState.loading,
94 task: taskState.data,
95 taskFailure: failureOf(taskState),
96 taskLoading: taskState.loading,
97 messages: messagesState.data?.items ?? [],
98 events: eventsState.data?.items ?? [],
99 }
100}
No newline at end of file
Deleted web/src/features/agent-console/ui/agent-collab.tsx web/src/features/agent-console/ui/agent-collab.tsx +0 −279
@@ -1,279 +0,0 @@
1/**
2 * Collaboration panels of the task detail: labels, members and artifacts.
3 *
4 * Each panel owns its own fetch so a failing endpoint only blanks that panel
5 * (the detail body and the transcript keep working). Labels are created on the
6 * collection — a label is bound to a task, but it lives on the collection's
7 * `dir` node — so the create dialog posts to the collection, never the task.
8 *
9 * Artifacts come from the sandbox, so the panel only lists and downloads them:
10 * the download link is a short-lived presigned URL minted per response, which is
11 * why it is opened directly instead of being routed through the SPA.
12 */
13import { useState } from 'react'
14import { Download, Plus, X } from 'lucide-react'
15import { Alert, Button, Chip, EmptyNote, FormModal, useSave, type FormField, type FormValues } from '@/shared/ui'
16import { useT } from '@/shared/i18n'
17import { agentKey, spaceMembersPageKey, useAsync } from '@/shared/lib'
18import {
19 addMember,
20 artifactSize,
21 bindTaskLabel,
22 createLabel,
23 listArtifacts,
24 listLabels,
25 listMembers,
26 listSpaceMembers,
27 listTaskLabels,
28 MEMBER_KIND_LABELS,
29 removeMember,
30 unbindTaskLabel,
31 type AgentRef,
32} from '@/entities/agent'
33
34export interface TaskCollabProps {
35 agentRef: AgentRef
36 taskId: string
37 onChanged: () => void
38}
39
40const sectionTitleCls =
41 'text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted'
42
43/* ------------------------------------------------------------------ labels */
44
45export function TaskLabels({ agentRef, taskId, onChanged }: TaskCollabProps) {
46 const { t } = useT()
47 const save = useSave()
48 const [reload, setReload] = useState(0)
49 const [creating, setCreating] = useState(false)
50
51 const catalog = useAsync(() => listLabels(agentRef), [agentRef.space, agentRef.collection, reload], {
52 cacheKey: agentKey(agentRef.space, agentRef.collection, 'labels'),
53 })
54 const bound = useAsync(() => listTaskLabels(agentRef, taskId), [agentRef.space, agentRef.collection, taskId, reload])
55
56 const boundLabels = bound.data ?? []
57 const boundIds = new Set(boundLabels.map(label => label.id))
58 const available = (catalog.data?.items ?? []).filter(label => !boundIds.has(label.id) && !label.is_archived)
59
60 const refresh = () => setReload(tick => tick + 1)
61
62 const bind = async (labelId: string) => {
63 if (!labelId) return
64 await save.run(() => bindTaskLabel(agentRef, taskId, labelId))
65 refresh()
66 onChanged()
67 }
68
69 const unbind = async (labelId: string) => {
70 await save.run(() => unbindTaskLabel(agentRef, taskId, labelId))
71 refresh()
72 onChanged()
73 }
74
75 async function create(values: FormValues) {
76 const name = String(values.name ?? '').trim()
77 if (!name) throw new Error(t('agent.labels.nameRequired'))
78 await createLabel(agentRef, {
79 name,
80 color: String(values.color ?? '').trim() || '#66717d',
81 description: String(values.description ?? '').trim() || undefined,
82 })
83 refresh()
84 }
85
86 const fields: FormField[] = [
87 { kind: 'text', key: 'name', label: t('agent.labels.name'), maxLength: 50 },
88 { kind: 'text', key: 'color', label: t('agent.labels.color'), initialValue: '#4f8ef7', maxLength: 7 },
89 { kind: 'textarea', key: 'description', label: t('agent.console.form.description'), optional: true, rows: 2 },
90 ]
91
92 return (
93 <section className="flex flex-col gap-2">
94 <h3 className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
95 {t('agent.labels.title')}
96 <button
97 type="button"
98 onClick={() => setCreating(true)}
99 className="inline-flex items-center gap-0.5 text-[11px] font-normal normal-case tracking-normal text-shell-ink-muted hover:text-shell-ink"
100 >
101 <Plus className="h-3 w-3" />
102 {t('agent.labels.new')}
103 </button>
104 </h3>
105 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
106 {boundLabels.length === 0 ? (
107 <EmptyNote>{t('agent.labels.empty')}</EmptyNote>
108 ) : (
109 <div className="flex flex-wrap items-center gap-1.5">
110 {boundLabels.map(label => (
111 <span
112 key={label.id}
113 className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
114 style={{ backgroundColor: label.color }}
115 >
116 {label.name}
117 <button
118 type="button"
119 title={t('agent.labels.unbind')}
120 aria-label={t('agent.labels.unbind')}
121 onClick={() => void unbind(label.id)}
122 >
123 <X className="h-3 w-3" />
124 </button>
125 </span>
126 ))}
127 </div>
128 )}
129 {available.length > 0 ? (
130 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
131 {t('agent.labels.add')}
132 <select
133 value=""
134 onChange={event => void bind(event.target.value)}
135 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
136 >
137 <option value="">—</option>
138 {available.map(label => (
139 <option key={label.id} value={label.id}>
140 {label.name}
141 </option>
142 ))}
143 </select>
144 </label>
145 ) : null}
146 <FormModal
147 title={t('agent.labels.createTitle')}
148 open={creating}
149 fields={fields}
150 confirmLabel={t('common.action.create')}
151 onSubmit={create}
152 onClose={() => setCreating(false)}
153 />
154 </section>
155 )
156}
157
158/* ----------------------------------------------------------------- members */
159
160export function TaskMembers({ agentRef, taskId, onChanged }: TaskCollabProps) {
161 const { t } = useT()
162 const save = useSave()
163 const [reload, setReload] = useState(0)
164
165 const members = useAsync(() => listMembers(agentRef, taskId), [agentRef.space, agentRef.collection, taskId, reload])
166 const spaceMembers = useAsync(() => listSpaceMembers(agentRef.space), [agentRef.space], {
167 cacheKey: spaceMembersPageKey(agentRef.space),
168 })
169
170 const rows = members.data?.items ?? []
171 const taken = new Set(rows.map(row => row.user?.user_id).filter(Boolean) as string[])
172 const candidates = (spaceMembers.data?.items ?? []).filter(row => row.user?.user_id && !taken.has(row.user.user_id))
173
174 const refresh = () => setReload(tick => tick + 1)
175
176 const add = async (userId: string) => {
177 if (!userId) return
178 await save.run(() => addMember(agentRef, taskId, { user_id: userId, kind: 'assignee' }))
179 refresh()
180 onChanged()
181 }
182
183 const remove = async (userId: string) => {
184 await save.run(() => removeMember(agentRef, taskId, userId))
185 refresh()
186 onChanged()
187 }
188
189 return (
190 <section className="flex flex-col gap-2">
191 <h3 className={sectionTitleCls}>{t('agent.member.title')}</h3>
192 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
193 {rows.length === 0 ? (
194 <EmptyNote>{t('agent.member.empty')}</EmptyNote>
195 ) : (
196 <div className="flex flex-wrap items-center gap-1.5">
197 {rows.map(row => {
198 const userId = row.user?.user_id ?? ''
199 return (
200 <Chip key={row.id}>
201 <span>{row.user?.display_name ?? row.user?.username ?? userId}</span>
202 <span className="text-shell-ink-faint">{MEMBER_KIND_LABELS[row.kind]}</span>
203 {userId ? (
204 <button
205 type="button"
206 title={t('agent.member.remove')}
207 aria-label={t('agent.member.remove')}
208 onClick={() => void remove(userId)}
209 >
210 <X className="h-3 w-3" />
211 </button>
212 ) : null}
213 </Chip>
214 )
215 })}
216 </div>
217 )}
218 {candidates.length > 0 ? (
219 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
220 {t('agent.member.add')}
221 <select
222 value=""
223 onChange={event => void add(event.target.value)}
224 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
225 >
226 <option value="">—</option>
227 {candidates.map(row => (
228 <option key={row.user?.user_id} value={row.user?.user_id}>
229 {row.alias_name ?? row.user?.display_name ?? row.user?.username}
230 </option>
231 ))}
232 </select>
233 </label>
234 ) : null}
235 </section>
236 )
237}
238
239/* --------------------------------------------------------------- artifacts */
240
241export function TaskArtifacts({ agentRef, taskId }: TaskCollabProps) {
242 const { t } = useT()
243 const artifacts = useAsync(() => listArtifacts(agentRef, taskId), [agentRef.space, agentRef.collection, taskId])
244 const rows = artifacts.data?.items ?? []
245
246 const download = (url: string | null | undefined) => {
247 if (!url) return
248 window.open(url, '_blank', 'noopener')
249 }
250
251 return (
252 <section className="flex flex-col gap-2">
253 <h3 className={sectionTitleCls}>{t('agent.artifact.title')}</h3>
254 {rows.length === 0 ? (
255 <EmptyNote>{t('agent.artifact.empty')}</EmptyNote>
256 ) : (
257 <ul className="flex flex-col gap-1.5">
258 {rows.map(artifact => (
259 <li key={artifact.id} className="flex items-center gap-2 text-[12.5px]">
260 <span className="min-w-0 flex-1 truncate">{artifact.name}</span>
261 <Chip>{artifact.kind}</Chip>
262 <span className="text-[11.5px] text-shell-ink-muted">{artifactSize(artifact.size)}</span>
263 <Button
264 variant="ghost"
265 className="h-6 px-2 text-[11.5px]"
266 disabled={!artifact.download_url}
267 title={artifact.download_url ? undefined : t('agent.artifact.missingLink')}
268 onClick={() => download(artifact.download_url)}
269 >
270 <Download className="h-3 w-3" />
271 {t('agent.artifact.download')}
272 </Button>
273 </li>
274 ))}
275 </ul>
276 )}
277 </section>
278 )
279}
No newline at end of file
Deleted web/src/features/agent-console/ui/agent-console.tsx web/src/features/agent-console/ui/agent-console.tsx +0 −174
@@ -1,174 +0,0 @@
1/**
2 * The console body: the same rows as cards or as a table.
3 *
4 * The API has no board concept — a task is a state plus a priority — so there
5 * are no columns and no client-side filtering: every control in the toolbar is
6 * a real query parameter. Loading / empty / error / out-of-range paging are all
7 * rendered from the request state, never from placeholder data.
8 */
9import { ChevronLeft, ChevronRight, Inbox } from 'lucide-react'
10import { Button, ErrorPanel } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import type { AgentTask } from '@/entities/agent'
13import type { AgentConsoleData } from '../model/use-agent-console-data'
14import { AGENT_PAGE_SIZE } from '../model/use-agent-console-data'
15import type { AgentConsoleView } from '../model/query'
16import { ConsoleEmpty, PriorityBadge, StateBadge, TaskCard, TaskMeta } from './parts'
17
18export interface AgentConsoleProps {
19 data: AgentConsoleData
20 view: AgentConsoleView
21 onFilter: (patch: Partial<AgentConsoleView>) => void
22 onOpenTask: (task: AgentTask) => void
23 onCreate?: () => void
24 onRetry: () => void
25}
26
27export function AgentConsole({ data, view, onFilter, onOpenTask, onCreate, onRetry }: AgentConsoleProps) {
28 const { t } = useT()
29 const { tasks, total, tasksFailure, tasksLoading } = data
30 const limit = AGENT_PAGE_SIZE
31 const pages = Math.max(1, Math.ceil(total / limit))
32 const offset = (view.page - 1) * limit
33
34 if (tasksFailure && tasks.length === 0) {
35 return (
36 <div className="w-full rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
37 <ErrorPanel
38 status={tasksFailure.code}
39 what={t('agent.console.errorList')}
40 note={tasksFailure.message}
41 onRetry={onRetry}
42 />
43 </div>
44 )
45 }
46
47 if (tasksLoading && tasks.length === 0) {
48 return (
49 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
50 {Array.from({ length: 6 }, (_, i) => (
51 <div key={i} className="space-y-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-3">
52 <div className="h-4 w-2/3 rounded-full bg-shell-field" />
53 <div className="h-3 w-full rounded-full bg-shell-field" />
54 <div className="h-5 w-20 rounded-full bg-shell-field" />
55 </div>
56 ))}
57 </div>
58 )
59 }
60
61 // Paging past the end (a stale `?page=`, or tasks deleted meanwhile) would
62 // otherwise read as "no results" — say what it is and offer the way back.
63 if (tasks.length === 0 && offset > 0) {
64 return (
65 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-10 text-center">
66 <p className="text-[13px]">
67 {t('agent.console.pageOutOfRange', { page: view.page, total, limit })}
68 </p>
69 <Button variant="secondary" onClick={() => onFilter({ page: 1 })}>
70 {t('agent.console.backToFirst')}
71 </Button>
72 </div>
73 )
74 }
75
76 if (tasks.length === 0) {
77 const filtered = view.state !== 'all' || view.q !== '' || view.priority !== ''
78 return filtered ? (
79 <ConsoleEmpty icon={<Inbox className="h-6 w-6 text-shell-ink-muted" />} title={t('agent.console.emptyFiltered')} />
80 ) : (
81 <ConsoleEmpty
82 icon={<Inbox className="h-6 w-6 text-shell-ink-muted" />}
83 title={t('agent.console.empty')}
84 note={t('agent.console.emptyNote')}
85 />
86 )
87 }
88
89 return (
90 <div className="flex flex-col gap-3">
91 {view.layout === 'cards' ? (
92 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
93 {tasks.map(task => (
94 <TaskCard
95 key={task.id}
96 task={task}
97 active={view.task === String(task.number) || view.task === task.id}
98 onOpen={() => onOpenTask(task)}
99 />
100 ))}
101 </div>
102 ) : (
103 <div className="overflow-hidden rounded-lg border-2 border-shell-hairline bg-shell-surface">
104 <table className="w-full border-collapse text-left">
105 <thead>
106 <tr className="border-b border-shell-hairline text-[11.5px] uppercase tracking-[0.4px] text-shell-ink-muted">
107 <th className="px-3 py-2 font-medium">{t('agent.console.task.number')}</th>
108 <th className="px-3 py-2 font-medium">{t('agent.console.task.title')}</th>
109 <th className="px-3 py-2 font-medium">{t('agent.console.task.state')}</th>
110 <th className="px-3 py-2 font-medium">{t('agent.console.task.priority')}</th>
111 <th className="px-3 py-2 font-medium">{t('agent.console.task.createdAt')}</th>
112 </tr>
113 </thead>
114 <tbody>
115 {tasks.map(task => (
116 <tr key={task.id} className="border-b border-shell-hairline last:border-b-0 hover:bg-shell-field">
117 <td className="px-3 py-2 font-mono text-[12px] text-shell-ink-muted">{`#${task.number}`}</td>
118 <td className="px-3 py-2">
119 <button
120 type="button"
121 onClick={() => onOpenTask(task)}
122 className="max-w-[420px] truncate text-left text-[13px] font-medium hover:underline"
123 >
124 {task.title}
125 </button>
126 </td>
127 <td className="px-3 py-2">
128 <StateBadge state={task.state} />
129 </td>
130 <td className="px-3 py-2">
131 <PriorityBadge priority={task.priority} compact />
132 </td>
133 <td className="px-3 py-2 text-[12px] text-shell-ink-muted">
134 <TaskMeta task={task} />
135 </td>
136 </tr>
137 ))}
138 </tbody>
139 </table>
140 </div>
141 )}
142
143 <div className="flex items-center justify-between gap-2 px-1 text-[12px] text-shell-ink-muted">
144 <span>{t('agent.console.pager', { page: view.page, pages, total })}</span>
145 <span className="flex items-center gap-2">
146 <Button
147 variant="secondary"
148 className="h-7 px-2 text-[12px]"
149 disabled={view.page <= 1}
150 onClick={() => onFilter({ page: view.page - 1 })}
151 >
152 <ChevronLeft className="h-3.5 w-3.5" />
153 {t('agent.console.prev')}
154 </Button>
155 <Button
156 variant="secondary"
157 className="h-7 px-2 text-[12px]"
158 disabled={view.page >= pages}
159 onClick={() => onFilter({ page: view.page + 1 })}
160 >
161 {t('agent.console.next')}
162 <ChevronRight className="h-3.5 w-3.5" />
163 </Button>
164 </span>
165 </div>
166
167 {onCreate && tasks.length > 0 && total === 0 ? (
168 <Button variant="ghost" className="self-start" onClick={onCreate}>
169 {t('agent.console.new')}
170 </Button>
171 ) : null}
172 </div>
173 )
174}
No newline at end of file
Deleted web/src/features/agent-console/ui/agent-detail.tsx web/src/features/agent-console/ui/agent-detail.tsx +0 −428
@@ -1,428 +0,0 @@
1/**
2 * Task detail panel.
3 *
4 * The body is editable only while the task is a draft (the backend freezes it
5 * once a run starts), so the panel reads `state` to decide which actions exist
6 * instead of letting the user click into a 409. Everything else — messages and
7 * the activity feed — is a read-only mirror of the gateway session.
8 */
9import { useEffect, useState } from 'react'
10import { ArrowLeft, Bot, CheckCheck, CirclePlay, Pencil, Send, Square, Trash2, Undo2, User } from 'lucide-react'
11import { Alert, Button, ErrorPanel, Markdown } from '@/shared/ui'
12import { useSave } from '@/shared/ui'
13import { useT, type MessageKey } from '@/shared/i18n'
14import { fmtRelative, fmtTime } from '@/shared/lib'
15import { mergeAgentMessages, useAgentStream, type AgentStreamStatus } from '@/shared/sagent'
16import { peekCache, writeCache } from '@/shared/storage'
17import {
18 cancelTask,
19 closeTask,
20 eventLabel,
21 describeEventContent,
22 getReadState,
23 getTaskToken,
24 isActiveState,
25 markTaskRead,
26 priorityOf,
27 reopenTask,
28 ROLE_LABELS,
29 setTaskWatch,
30 startTask,
31 stateOf,
32 WATCH_LABELS,
33 WATCH_MODES,
34 type AgentMessage,
35 type AgentReadState,
36 type AgentRef,
37 type WatchMode,
38} from '@/entities/agent'
39import type { AgentConsoleData } from '../model/use-agent-console-data'
40import { TaskArtifacts, TaskLabels, TaskMembers } from './agent-collab'
41
42/** A transcript row, whichever side of the wire it came from. */
43interface Turn {
44 seq: number
45 role: keyof typeof ROLE_LABELS
46 content: string
47 at: string
48}
49
50const ARCHIVE_TURNS = (messages: readonly AgentMessage[]): Turn[] =>
51 messages.map(message => ({
52 seq: message.seq,
53 role: message.role,
54 content: message.content,
55 at: message.created_at,
56 }))
57
58/** Live frames use the gateway's vocabulary: `assistant` is our `agent`. */
59const LIVE_TURNS = (messages: readonly { seq: number; role: string; content: string; created_at_millis: number }[]): Turn[] =>
60 messages.map(message => ({
61 seq: message.seq,
62 role: message.role === 'assistant' ? 'agent' : 'user',
63 content: message.content,
64 at: new Date(message.created_at_millis).toISOString(),
65 }))
66
67const STREAM_LABELS = {
68 idle: 'agent.console.stream.idle',
69 connecting: 'agent.console.stream.connecting',
70 live: 'agent.console.stream.live',
71 reconnecting: 'agent.console.stream.reconnecting',
72 expired: 'agent.console.stream.expired',
73 error: 'agent.console.stream.error',
74 stopped: 'agent.console.stream.stopped',
75} as const satisfies Record<AgentStreamStatus, MessageKey>
76
77export interface AgentDetailProps {
78 agentRef: AgentRef
79 data: AgentConsoleData
80 onEdit: () => void
81 onChanged: () => void
82 onClose: () => void
83 onDelete: () => void
84}
85
86export function AgentDetail({ agentRef, data, onEdit, onChanged, onClose, onDelete }: AgentDetailProps) {
87 const { t } = useT()
88 const save = useSave()
89 const { task, messages, events } = data
90
91 // The transcript is a read-only mirror in the API host; live frames come
92 // straight from the gateway session so a running task does not need polling.
93 const live = useAgentStream({
94 sessionId: task?.session_id ?? null,
95 fetchToken: async () => {
96 const target = data.task
97 if (!target) throw new Error('agent task is not loaded')
98 const token = await getTaskToken(agentRef, target.id)
99 return {
100 sessionId: token.session_id,
101 token: token.token,
102 baseUrl: token.base_url,
103 expiresAt: token.expires_at,
104 }
105 },
106 })
107 const [draft, setDraft] = useState('')
108 const [sending, setSending] = useState(false)
109 const [sendError, setSendError] = useState<string | null>(null)
110 // The read cursor is per user and lives server-side; the panel only mirrors
111 // it so an opening user can see how much happened since the last visit.
112 const [readState, setReadState] = useState<AgentReadState | null>(null)
113 const taskId = task?.id ?? null
114
115 useEffect(() => {
116 if (!taskId) return
117 let active = true
118 getReadState(agentRef, taskId)
119 .then(state => {
120 if (active) setReadState(state)
121 })
122 .catch(() => undefined)
123 return () => {
124 active = false
125 }
126 }, [agentRef, taskId])
127 const transcript = mergeAgentMessages(ARCHIVE_TURNS(messages), LIVE_TURNS(live.messages))
128 const streamAlarm = live.status === 'error' || live.status === 'expired' || live.status === 'reconnecting'
129
130 // There is no read endpoint for the watch row, so the panel remembers the
131 // last mode this browser set and shows that; the server stays authoritative.
132 const watchCacheKey = `agent-watch:${agentRef.space}/${agentRef.collection}/${task?.id ?? ''}`
133 const [watch, setWatch] = useState<WatchMode>(() => peekCache<WatchMode>(watchCacheKey)?.data ?? 'unwatched')
134
135 if (!task) {
136 if (data.taskFailure) {
137 return (
138 <article className="rounded-lg border-2 border-shell-hairline bg-shell-surface p-6">
139 <ErrorPanel
140 status={data.taskFailure.code}
141 what={t('agent.console.errorTask')}
142 note={data.taskFailure.message}
143 onRetry={onChanged}
144 />
145 </article>
146 )
147 }
148 return (
149 <article className="flex flex-col gap-2 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
150 <span className="h-5 w-1/2 rounded-full bg-shell-field" />
151 <span className="h-4 w-1/3 rounded-full bg-shell-field" />
152 <span className="h-24 w-full rounded-md bg-shell-field" />
153 </article>
154 )
155 }
156
157 const meta = stateOf(task.state)
158 const priority = priorityOf(task.priority)
159 const active = isActiveState(task.state)
160 const closed = task.state === 'closed'
161 const started = Boolean(task.started_at)
162
163 const run = async (action: 'start' | 'cancel' | 'close' | 'reopen') => {
164 await save.run(async () => {
165 if (action === 'start') await startTask(agentRef, task.id)
166 else if (action === 'cancel') await cancelTask(agentRef, task.id)
167 else if (action === 'close') await closeTask(agentRef, task.id)
168 else await reopenTask(agentRef, task.id)
169 onChanged()
170 })
171 }
172
173 const changeWatch = async (mode: WatchMode) => {
174 setWatch(mode)
175 writeCache(watchCacheKey, mode)
176 await save.run(() => setTaskWatch(agentRef, task.id, mode))
177 }
178
179 const markRead = async () => {
180 await save.run(async () => {
181 setReadState(await markTaskRead(agentRef, task.id))
182 })
183 }
184
185 // `send` goes straight to the gateway session; the API host has no send route
186 // on purpose, so quota and moderation hooks do not exist for follow-ups.
187 const sendDraft = async () => {
188 const text = draft.trim()
189 if (text === '') return
190 setSending(true)
191 setSendError(null)
192 try {
193 await live.send(text)
194 setDraft('')
195 } catch (cause) {
196 setSendError(cause instanceof Error ? cause.message : String(cause))
197 } finally {
198 setSending(false)
199 }
200 }
201
202 return (
203 <article className="flex flex-col gap-4 rounded-lg border-2 border-shell-hairline bg-shell-surface p-4">
204 <header className="flex items-start gap-2">
205 <button
206 type="button"
207 onClick={onClose}
208 title={t('agent.console.task.back')}
209 aria-label={t('agent.console.task.back')}
210 className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-2 border-shell-hairline text-shell-ink-muted hover:border-shell-ink-muted"
211 >
212 <ArrowLeft className="h-3.5 w-3.5" />
213 </button>
214 <div className="flex min-w-0 flex-1 flex-col gap-1.5">
215 <div className="flex items-center gap-2">
216 <span className="font-mono text-[12px] text-shell-ink-muted">{`#${task.number}`}</span>
217 <span className="text-[15px] font-semibold">{task.title}</span>
218 </div>
219 <div className="flex flex-wrap items-center gap-2">
220 <span
221 className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
222 style={{ backgroundColor: meta.color }}
223 >
224 {meta.label}
225 </span>
226 <span
227 className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
228 style={{ backgroundColor: priority.color, color: priority.text }}
229 >
230 {priority.label}
231 </span>
232 {task.due_at ? (
233 <span className="text-[11.5px] text-shell-ink-muted">
234 {t('agent.console.form.dueAt')}
235 {` ${fmtTime(task.due_at)}`}
236 </span>
237 ) : null}
238 {readState && readState.unread > 0 ? (
239 <button
240 type="button"
241 onClick={() => void markRead()}
242 disabled={save.busy}
243 className="inline-flex items-center gap-1 rounded-full bg-shell-field px-2 py-0.5 text-[11px] font-semibold text-shell-ink-muted hover:text-shell-ink"
244 >
245 <CheckCheck className="h-3 w-3" />
246 {t('agent.read.unread', { count: readState.unread })}
247 </button>
248 ) : null}
249 </div>
250 </div>
251 </header>
252
253 <div className="flex flex-wrap items-center gap-2">
254 {task.state === 'draft' || task.state === 'pending' ? (
255 <Button className="h-7 px-3 text-[12.5px]" disabled={save.busy} onClick={() => run('start')}>
256 <CirclePlay className="h-3.5 w-3.5" />
257 {t('agent.console.task.start')}
258 </Button>
259 ) : null}
260 {active ? (
261 <Button
262 variant="secondary"
263 className="h-7 px-3 text-[12.5px]"
264 disabled={save.busy}
265 onClick={() => run('cancel')}
266 >
267 <Square className="h-3.5 w-3.5" />
268 {t('agent.console.task.cancel')}
269 </Button>
270 ) : null}
271 {closed ? (
272 <Button
273 variant="secondary"
274 className="h-7 px-3 text-[12.5px]"
275 disabled={save.busy}
276 onClick={() => run('reopen')}
277 >
278 <Undo2 className="h-3.5 w-3.5" />
279 {t('agent.console.task.reopen')}
280 </Button>
281 ) : (
282 <Button
283 variant="secondary"
284 className="h-7 px-3 text-[12.5px]"
285 disabled={save.busy}
286 onClick={() => run('close')}
287 >
288 <Square className="h-3.5 w-3.5" />
289 {t('agent.console.task.close')}
290 </Button>
291 )}
292 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onEdit}>
293 <Pencil className="h-3.5 w-3.5" />
294 {t('agent.console.task.edit')}
295 </Button>
296 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onDelete}>
297 <Trash2 className="h-3.5 w-3.5" />
298 {t('agent.console.task.delete')}
299 </Button>
300 <label className="ml-auto flex items-center gap-1 text-[12px] text-shell-ink-muted">
301 {t('agent.console.task.watch')}
302 <select
303 value={watch}
304 onChange={event => void changeWatch(event.target.value as WatchMode)}
305 className="h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
306 >
307 {WATCH_MODES.map(mode => (
308 <option key={mode} value={mode}>
309 {WATCH_LABELS[mode]}
310 </option>
311 ))}
312 </select>
313 </label>
314 </div>
315
316 {save.error ? <Alert>{`${t('agent.console.actionFailed')}: ${save.error}`}</Alert> : null}
317 {!started && (task.state === 'draft' || task.state === 'pending') ? (
318 <p className="rounded-md bg-shell-field px-3 py-2 text-[12px] text-shell-ink-muted">
319 {t('agent.console.task.startHint')}
320 </p>
321 ) : null}
322
323 <section className="flex flex-col gap-1.5">
324 <h3 className="text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
325 {t('agent.console.task.body')}
326 </h3>
327 {task.body ? (
328 <div className="rounded-md border-2 border-shell-hairline px-3 py-2">
329 <Markdown>{task.body}</Markdown>
330 </div>
331 ) : (
332 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noBody')}</p>
333 )}
334 {started ? <p className="text-[11.5px] text-shell-ink-muted">{t('agent.console.task.bodyFrozen')}</p> : null}
335 </section>
336
337 <TaskLabels agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
338
339 <TaskMembers agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
340
341 <section className="flex flex-col gap-2">
342 <h3 className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
343 {t('agent.console.task.conversation')}
344 <span
345 className={`text-[11px] font-normal normal-case tracking-normal ${streamAlarm ? 'text-error' : ''}`}
346 title={live.error ?? undefined}
347 >
348 {t(STREAM_LABELS[live.status])}
349 </span>
350 </h3>
351 {transcript.length === 0 ? (
352 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noMessages')}</p>
353 ) : (
354 <ol className="flex flex-col gap-2">
355 {transcript.map(turn => (
356 <li key={turn.seq} className="flex gap-2">
357 <span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-shell-field text-shell-ink-muted">
358 {turn.role === 'user' ? <User className="h-3 w-3" /> : <Bot className="h-3 w-3" />}
359 </span>
360 <div className="flex min-w-0 flex-1 flex-col gap-0.5">
361 <span className="flex items-center gap-2 text-[11.5px] text-shell-ink-muted">
362 <span className="font-semibold">{ROLE_LABELS[turn.role] ?? turn.role}</span>
363 <span>{fmtRelative(turn.at)}</span>
364 <span className="font-mono">{`#${turn.seq}`}</span>
365 </span>
366 <div className="text-[12.5px] leading-[1.6]">
367 <Markdown>{turn.content}</Markdown>
368 </div>
369 </div>
370 </li>
371 ))}
372 </ol>
373 )}
374 {task.session_id ? (
375 <div className="flex flex-col gap-1.5">
376 <div className="flex items-end gap-2">
377 <textarea
378 value={draft}
379 rows={2}
380 placeholder={t('agent.console.task.composerPlaceholder')}
381 onChange={event => setDraft(event.target.value)}
382 onKeyDown={event => {
383 if (event.key === 'Enter' && !event.shiftKey) {
384 event.preventDefault()
385 void sendDraft()
386 }
387 }}
388 className="min-h-16 flex-1 resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink"
389 />
390 <Button
391 className="h-8 px-3 text-[12.5px]"
392 disabled={sending || draft.trim() === ''}
393 onClick={() => void sendDraft()}
394 >
395 <Send className="h-3.5 w-3.5" />
396 {t('agent.console.task.send')}
397 </Button>
398 </div>
399 {sendError ? <Alert>{`${t('agent.console.task.sendFailed')}: ${sendError}`}</Alert> : null}
400 </div>
401 ) : null}
402 </section>
403
404 <section className="flex flex-col gap-2">
405 <h3 className="text-[12px] font-semibold uppercase tracking-[0.4px] text-shell-ink-muted">
406 {t('agent.console.task.activity')}
407 </h3>
408 {events.length === 0 ? (
409 <p className="text-[12.5px] text-shell-ink-muted">{t('agent.console.task.noEvents')}</p>
410 ) : (
411 <ol className="flex flex-col gap-1.5">
412 {events.map(event => (
413 <li key={event.id} className="flex items-baseline gap-2 text-[12px]">
414 <span className="font-mono text-[11px] text-shell-ink-muted">{fmtRelative(event.created_at)}</span>
415 <span>{eventLabel(event.event_type)}</span>
416 {describeEventContent(event.content) ? (
417 <span className="text-shell-ink-muted">{describeEventContent(event.content)}</span>
418 ) : null}
419 </li>
420 ))}
421 </ol>
422 )}
423 </section>
424
425 <TaskArtifacts agentRef={agentRef} taskId={task.id} onChanged={onChanged} />
426 </article>
427 )
428}
No newline at end of file
Deleted web/src/features/agent-console/ui/agent-filters.tsx web/src/features/agent-console/ui/agent-filters.tsx +0 −125
@@ -1,125 +0,0 @@
1/**
2 * Toolbar of the 智能体 console: the server-side filters (state, priority, sort,
3 * full-text), the layout switch and the primary action. Every control writes
4 * through `onFilter`, which patches the URL — so a filtered view is
5 * shareable and survives a reload, and there is no client-side filtering.
6 */
7import { LayoutGrid, List, Plus, RotateCcw, Search } from 'lucide-react'
8import { Button } from '@/shared/ui'
9import { useT } from '@/shared/i18n'
10import { PRIORITIES, SORTS, STATE_FILTERS } from '@/entities/agent'
11import type { AgentConsoleView } from '../model/query'
12
13export interface AgentFiltersProps {
14 view: AgentConsoleView
15 onFilter: (patch: Partial<AgentConsoleView>) => void
16 onClear: () => void
17}
18
19const controlCls =
20 'h-7 rounded-md border-2 border-shell-hairline bg-shell-field px-2 text-[12.5px] text-shell-ink outline-none focus:border-shell-ink'
21
22export function AgentFilters({ view, onFilter, onClear }: AgentFiltersProps) {
23 const { t } = useT()
24 const filtered = view.state !== 'all' || view.q !== '' || view.priority !== ''
25 return (
26 <>
27 <div className="flex flex-wrap items-center gap-1.5">
28 {STATE_FILTERS.map(item => (
29 <button
30 key={item.id}
31 type="button"
32 onClick={() => onFilter({ state: item.id, page: 1 })}
33 className={`h-7 rounded-md border-2 px-2.5 text-[12.5px] transition-colors ${
34 view.state === item.id
35 ? 'border-shell-ink bg-shell-tile font-semibold'
36 : 'border-shell-hairline hover:border-shell-ink-muted'
37 }`}
38 >
39 {item.label}
40 </button>
41 ))}
42 </div>
43
44 <label className="relative flex items-center">
45 <Search className="pointer-events-none absolute left-2 h-3.5 w-3.5 text-shell-ink-muted" aria-hidden />
46 <input
47 value={view.q}
48 onChange={event => onFilter({ q: event.target.value, page: 1 })}
49 placeholder={t('agent.console.filter.search')}
50 aria-label={t('agent.console.filter.search')}
51 className={`${controlCls} w-44 pl-7`}
52 />
53 </label>
54
55 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
56 {t('agent.console.filter.priority')}
57 <select
58 value={view.priority}
59 onChange={event => onFilter({ priority: event.target.value as AgentConsoleView['priority'], page: 1 })}
60 className={controlCls}
61 >
62 <option value="">—</option>
63 {PRIORITIES.map(meta => (
64 <option key={meta.id} value={meta.id}>
65 {meta.label}
66 </option>
67 ))}
68 </select>
69 </label>
70
71 <label className="flex items-center gap-1 text-[12px] text-shell-ink-muted">
72 {t('agent.console.filter.sort')}
73 <select
74 value={view.sort}
75 onChange={event => onFilter({ sort: event.target.value as AgentConsoleView['sort'], page: 1 })}
76 className={controlCls}
77 >
78 {SORTS.map(meta => (
79 <option key={meta.id} value={meta.id}>
80 {meta.label}
81 </option>
82 ))}
83 </select>
84 </label>
85
86 {filtered ? (
87 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={onClear}>
88 <RotateCcw className="h-3.5 w-3.5" />
89 {t('agent.console.filter.reset')}
90 </Button>
91 ) : null}
92 </>
93 )
94}
95
96export interface AgentActionsProps {
97 view: AgentConsoleView
98 onFilter: (patch: Partial<AgentConsoleView>) => void
99 onCreate?: () => void
100}
101
102export function AgentActions({ view, onFilter, onCreate }: AgentActionsProps) {
103 const { t } = useT()
104 const next = view.layout === 'cards' ? 'list' : 'cards'
105 return (
106 <>
107 <button
108 type="button"
109 onClick={() => onFilter({ layout: next })}
110 title={t(next === 'list' ? 'agent.console.viewList' : 'agent.console.viewCards')}
111 aria-label={t(next === 'list' ? 'agent.console.viewList' : 'agent.console.viewCards')}
112 className="flex h-7 items-center gap-1.5 rounded-md border-2 border-shell-hairline px-2 text-[12.5px] transition-colors hover:border-shell-ink-muted"
113 >
114 {view.layout === 'cards' ? <LayoutGrid className="h-3.5 w-3.5" /> : <List className="h-3.5 w-3.5" />}
115 {t(view.layout === 'cards' ? 'agent.console.viewCards' : 'agent.console.viewList')}
116 </button>
117 {onCreate ? (
118 <Button className="h-7 px-3 text-[12.5px]" onClick={onCreate}>
119 <Plus className="h-3.5 w-3.5" />
120 {t('agent.console.new')}
121 </Button>
122 ) : null}
123 </>
124 )
125}
No newline at end of file
Deleted web/src/features/agent-console/ui/agent-form-modal.tsx web/src/features/agent-console/ui/agent-form-modal.tsx +0 −122
@@ -1,122 +0,0 @@
1/**
2 * Create / edit a 智能体任务.
3 *
4 * The backend freezes `body` once a run has started (`Conflict`), so the field
5 * is only submitted while the task is still a draft; the rest of the form
6 * always applies. Empty optional fields are sent as `null` to clear them —
7 * `undefined` would mean “leave alone”, which is not what a blank input means.
8 */
9import { useMemo } from 'react'
10import { FormModal, type FormField, type FormValues } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import {
13 createTask,
14 dateApiValue,
15 dateInputValue,
16 PRIORITIES,
17 updateTask,
18 type AgentPriority,
19 type AgentRef,
20 type AgentTask,
21} from '@/entities/agent'
22
23export interface AgentFormModalProps {
24 agentRef: AgentRef
25 /** `null` creates a new task. */
26 task: AgentTask | null
27 open: boolean
28 onSaved: (task: AgentTask) => void
29 onClose: () => void
30}
31
32export function AgentFormModal({ agentRef, task, open, onSaved, onClose }: AgentFormModalProps) {
33 const { t } = useT()
34
35 const fields = useMemo<FormField[]>(
36 () => [
37 {
38 kind: 'text',
39 key: 'title',
40 label: t('agent.console.form.title'),
41 placeholder: t('agent.console.form.titlePlaceholder'),
42 maxLength: 255,
43 initialValue: task?.title ?? '',
44 },
45 {
46 kind: 'textarea',
47 key: 'description',
48 label: t('agent.console.form.description'),
49 optional: true,
50 rows: 2,
51 maxLength: 1024,
52 initialValue: task?.description ?? '',
53 },
54 {
55 kind: 'textarea',
56 key: 'body',
57 label: t('agent.console.form.body'),
58 hint: task && task.state !== 'draft' ? t('agent.console.task.bodyFrozen') : undefined,
59 optional: true,
60 rows: 10,
61 initialValue: task?.body ?? '',
62 },
63 {
64 kind: 'select',
65 key: 'priority',
66 label: t('agent.console.form.priority'),
67 options: PRIORITIES.map(meta => ({ value: meta.id, label: meta.label })),
68 initialValue: task?.priority ?? 'none',
69 },
70 {
71 kind: 'datetime',
72 key: 'due_at',
73 label: t('agent.console.form.dueAt'),
74 optional: true,
75 initialValue: dateInputValue(task?.due_at),
76 },
77 ],
78 [task, t],
79 )
80
81 async function submit(values: FormValues) {
82 const title = String(values.title ?? '').trim()
83 if (!title) throw new Error(t('agent.console.form.titleRequired'))
84 const description = String(values.description ?? '').trim()
85 const body = String(values.body ?? '')
86 const priority = String(values.priority ?? 'none') as AgentPriority
87 const due = String(values.due_at ?? '')
88 const dueAt = due ? dateApiValue(due) : null
89
90 if (task) {
91 const saved = await updateTask(agentRef, task.id, {
92 title,
93 description: description || null,
94 due_at: dueAt,
95 priority,
96 ...(task.state === 'draft' ? { body } : {}),
97 })
98 onSaved(saved)
99 } else {
100 const saved = await createTask(agentRef, {
101 title,
102 description: description || undefined,
103 body,
104 priority,
105 due_at: dueAt,
106 })
107 onSaved(saved)
108 }
109 }
110
111 return (
112 <FormModal
113 title={t(task ? 'agent.console.form.editTitle' : 'agent.console.form.createTitle')}
114 open={open}
115 wide
116 fields={fields}
117 confirmLabel={t(task ? 'common.action.save' : 'common.action.create')}
118 onSubmit={submit}
119 onClose={onClose}
120 />
121 )
122}
No newline at end of file
Deleted web/src/features/agent-console/ui/collection-manage-modal.tsx web/src/features/agent-console/ui/collection-manage-modal.tsx +0 −116
@@ -1,116 +0,0 @@
1/**
2 * Rename / describe / delete a 智能体集.
3 *
4 * Writes go out on save (not on close), and the delete is a two-step confirm
5 * inside the panel: deleting the collection drops every task in it, so it must
6 * not be a single stray click.
7 */
8import { useState } from 'react'
9import { Button, Modal, modalInputClass, modalLabelClass, modalTextareaClass, useSave } from '@/shared/ui'
10import { Alert } from '@/shared/ui'
11import { useT } from '@/shared/i18n'
12import { deleteCollection, updateCollection, type AgentCollection, type AgentRef } from '@/entities/agent'
13
14export interface CollectionManageModalProps {
15 agentRef: AgentRef
16 collection?: AgentCollection
17 open: boolean
18 onSaved: () => void
19 onDeleted: () => void
20 onClose: () => void
21}
22
23export function CollectionManageModal({ agentRef, collection, open, onSaved, onDeleted, onClose }: CollectionManageModalProps) {
24 const { t } = useT()
25 const save = useSave()
26 const [name, setName] = useState(collection?.name ?? '')
27 const [description, setDescription] = useState(collection?.description ?? '')
28 const [icon, setIcon] = useState(collection?.icon ?? '')
29 const [confirming, setConfirming] = useState(false)
30
31 const dirty =
32 Boolean(collection) &&
33 (name.trim() !== collection?.name || description.trim() !== (collection?.description ?? '') || icon.trim() !== (collection?.icon ?? ''))
34
35 async function submit() {
36 if (!collection || !name.trim()) return
37 await save.run(async () => {
38 await updateCollection(agentRef.space, agentRef.collection, {
39 name: name.trim(),
40 description: description.trim() || null,
41 icon: icon.trim() || null,
42 })
43 onSaved()
44 })
45 }
46
47 async function remove() {
48 if (!collection) return
49 await save.run(async () => {
50 await deleteCollection(agentRef.space, agentRef.collection)
51 onDeleted()
52 })
53 }
54
55 return (
56 <Modal open={open} onClose={onClose} title={t('agent.console.manage')} width="max-w-[560px]">
57 <div className="space-y-4">
58 {save.error ? <Alert>{save.error}</Alert> : null}
59
60 <label className="block space-y-1.5">
61 <span className={modalLabelClass}>{t('space.agent.nameField')}</span>
62 <input className={modalInputClass} value={name} onChange={event => setName(event.target.value)} maxLength={100} />
63 </label>
64
65 <label className="block space-y-1.5">
66 <span className={modalLabelClass}>{t('space.agent.description')}</span>
67 <textarea
68 className={modalTextareaClass}
69 value={description}
70 onChange={event => setDescription(event.target.value)}
71 rows={3}
72 maxLength={512}
73 />
74 </label>
75
76 <label className="block space-y-1.5">
77 <span className={modalLabelClass}>{t('space.agent.icon')}</span>
78 <input className={modalInputClass} value={icon} onChange={event => setIcon(event.target.value)} maxLength={16} />
79 </label>
80
81 <p className="text-[12px] text-shell-ink-muted">
82 {t('agent.console.manage.stats', {
83 running: collection?.running_tasks ?? 0,
84 total: collection?.total_tasks ?? 0,
85 })}
86 </p>
87
88 <div className="flex items-center justify-between gap-3 border-t-2 border-shell-hairline pt-4">
89 {confirming ? (
90 <span className="flex items-center gap-2">
91 <span className="text-[12.5px] text-shell-ink-muted">{t('agent.console.manage.deleteConfirm')}</span>
92 <Button variant="secondary" className="h-7 px-2 text-[12.5px]" onClick={() => setConfirming(false)}>
93 {t('common.action.cancel')}
94 </Button>
95 <Button className="h-7 px-2 text-[12.5px]" disabled={save.busy} onClick={() => void remove()}>
96 {t('common.action.delete')}
97 </Button>
98 </span>
99 ) : (
100 <Button variant="ghost" className="h-7 px-2 text-[12.5px]" onClick={() => setConfirming(true)}>
101 {t('common.action.delete')}
102 </Button>
103 )}
104 <span className="ml-auto flex items-center gap-2">
105 <Button variant="secondary" className="h-7 px-3 text-[12.5px]" onClick={onClose}>
106 {t('common.action.close')}
107 </Button>
108 <Button className="h-7 px-3 text-[12.5px]" disabled={!dirty || save.busy} onClick={() => void submit()}>
109 {save.busy ? t('common.action.working') : t('common.action.save')}
110 </Button>
111 </span>
112 </div>
113 </div>
114 </Modal>
115 )
116}
No newline at end of file
Deleted web/src/features/agent-console/ui/parts.tsx web/src/features/agent-console/ui/parts.tsx +0 −87
@@ -1,87 +0,0 @@
1/**
2 * Small render pieces for the 智能体 console: badges and task cards. They own no
3 * data — every value comes from `@/entities/agent` metadata so the cards, the
4 * table and the detail panel render the same state the same way.
5 */
6import type { ReactNode } from 'react'
7import { Loader2 } from 'lucide-react'
8import { fmtRelative } from '@/shared/lib'
9import { useT } from '@/shared/i18n'
10import {
11 isActiveState,
12 priorityOf,
13 stateOf,
14 type AgentPriority,
15 type AgentState,
16 type AgentTask,
17} from '@/entities/agent'
18
19export function StateBadge({ state }: { state: AgentState | string }) {
20 const meta = stateOf(state)
21 return (
22 <span
23 className="inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold text-white"
24 style={{ backgroundColor: meta.color }}
25 >
26 {isActiveState(state) ? <Loader2 size={10} className="animate-spin" aria-hidden /> : null}
27 {meta.label}
28 </span>
29 )
30}
31
32export function PriorityBadge({ priority, compact = false }: { priority: AgentPriority; compact?: boolean }) {
33 const meta = priorityOf(priority)
34 return (
35 <span
36 className={`inline-flex shrink-0 items-center rounded-full font-semibold ${compact ? 'px-1.5 py-px text-[10.5px]' : 'px-2 py-0.5 text-[11px]'}`}
37 style={{ backgroundColor: meta.color, color: meta.text }}
38 >
39 {meta.label}
40 </span>
41 )
42}
43
44/** `#12 · 3 分钟前`, the meta line the cards and the table row share. */
45export function TaskMeta({ task }: { task: AgentTask }) {
46 const { t } = useT()
47 return (
48 <span className="flex shrink-0 items-center gap-2 text-[11px] text-shell-ink-muted">
49 <span className="font-mono">{`#${task.number}`}</span>
50 <span>{`· ${t('agent.console.task.createdAt')} ${fmtRelative(task.created_at)}`}</span>
51 </span>
52 )
53}
54
55export function TaskCard({ task, active, onOpen }: { task: AgentTask; active?: boolean; onOpen: () => void }) {
56 return (
57 <button
58 type="button"
59 onClick={onOpen}
60 className={`flex w-full flex-col gap-2 rounded-lg border-2 bg-shell-surface p-3 text-left transition-colors ${
61 active ? 'border-shell-ink' : 'border-shell-hairline hover:border-shell-ink-muted'
62 }`}
63 >
64 <div className="flex items-start justify-between gap-2">
65 <span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold">{task.title}</span>
66 <PriorityBadge priority={task.priority} compact />
67 </div>
68 {task.description ? (
69 <p className="line-clamp-2 text-[12px] leading-[1.5] text-shell-ink-muted">{task.description}</p>
70 ) : null}
71 <div className="flex items-center justify-between gap-2">
72 <StateBadge state={task.state} />
73 <TaskMeta task={task} />
74 </div>
75 </button>
76 )
77}
78
79export function ConsoleEmpty({ icon, title, note }: { icon?: ReactNode; title: string; note?: string }) {
80 return (
81 <div className="flex w-full flex-col items-center gap-2 rounded-lg border-2 border-dashed border-shell-hairline px-4 py-12 text-center">
82 {icon}
83 <p className="text-[13.5px] font-semibold">{title}</p>
84 {note ? <p className="text-[12.5px] text-shell-ink-muted">{note}</p> : null}
85 </div>
86 )
87}
No newline at end of file
Deleted web/src/features/agent-console/index.ts web/src/features/agent-console/index.ts +0 −22
@@ -1,22 +0,0 @@
1// Public API of features/agent-console — the 智能体 (sandbox agent) pages:
2// toolbar filters, cards/list body, task detail, and the create/edit/manage
3// dialogs. Consumers import from '@/features/agent-console', never deeper.
4export {
5 AGENT_PAGE_SIZE,
6 AGENT_TASK_PAGE_SIZE,
7 useAgentConsoleData,
8 type AgentConsoleData,
9} from './model/use-agent-console-data'
10export {
11 AGENT_VIEW_DEFAULTS,
12 buildAgentQuery,
13 normalizeAgentLayout,
14 type AgentConsoleView,
15 type AgentLayout,
16} from './model/query'
17export { prefetchAgentConsole } from './model/prefetch'
18export { AgentActions, AgentFilters } from './ui/agent-filters'
19export { AgentConsole } from './ui/agent-console'
20export { AgentDetail } from './ui/agent-detail'
21export { AgentFormModal } from './ui/agent-form-modal'
22export { CollectionManageModal } from './ui/collection-manage-modal'
No newline at end of file
Modified web/src/features/space-management/ui/space-header.tsx web/src/features/space-management/ui/space-header.tsx +1 −48 View web/src/features/space-management/ui/space-header.tsx
@@ -1,6 +1,5 @@
11import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
22import {
3 Bot,
43 ChevronDown,
54 Download,
65 FolderGit2,
@@ -25,7 +24,6 @@
2524 updatePreferences,
2625} from '../model/api'
2726import type { MySpace } from '../model/api'
28import { AGENT_CREATE_ENABLED, createCollection } from '@/entities/agent'
2927import { ImportRepoModal } from './import-repo-modal'
3028import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
3129import { FormModal, parseList } from '@/shared/ui'
@@ -38,7 +36,6 @@
3836 | 'repo'
3937 | 'import'
4038 | 'board'
41 | 'agent'
4239 | 'room'
4340 | 'dir'
4441 | 'privacy'
@@ -124,14 +121,13 @@
124121 setDialog(next)
125122 }
126123
127 // Parent pickers (folder/repo/board/agent/room) need fresh tree data on each open.
124 // Parent pickers (folder/repo/board/room) need fresh tree data on each open.
128125 useEffect(() => {
129126 if (
130127 dialog !== 'dir' &&
131128 dialog !== 'repo' &&
132129 dialog !== 'import' &&
133130 dialog !== 'board' &&
134 dialog !== 'agent' &&
135131 dialog !== 'room'
136132 )
137133 return
@@ -225,9 +221,6 @@
225221 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
226222 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
227223 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
228 {AGENT_CREATE_ENABLED ? (
229 <MenuItem icon={Bot} label={t('space.menu.createAgent')} onClick={() => openDialog('agent')} />
230 ) : null}
231224 {perms.has('manage_channels') ? (
232225 <MenuItem icon={Hash} label={t('space.menu.createRoom')} onClick={() => openDialog('room')} />
233226 ) : null}
@@ -406,46 +399,6 @@
406399 description: String(values.description).trim() || null,
407400 icon: String(values.icon).trim() || null,
408401 parent_dir_id: String(values.parent_dir_id) || null,
409 })
410 onListsChanged()
411 }}
412 onClose={closeDialog}
413 />
414 <FormModal
415 title={t('space.agent.create.title')}
416 open={dialog === 'agent'}
417 confirmLabel={t('common.action.create')}
418 fields={[
419 { kind: 'text', key: 'name', label: t('space.agent.nameField'), placeholder: 'triage-bot' },
420 {
421 kind: 'textarea',
422 key: 'description',
423 label: t('space.agent.description'),
424 optional: true,
425 maxLength: 512,
426 },
427 {
428 kind: 'text',
429 key: 'icon',
430 label: t('space.agent.icon'),
431 placeholder: '',
432 optional: true,
433 maxLength: 16,
434 },
435 {
436 kind: 'select',
437 key: 'parent_dir_id',
438 label: t('space.repo.dir'),
439 initialValue: '',
440 options: [{ value: '', label: t('space.dir.root') }, ...dirOptions],
441 },
442 ]}
443 onSubmit={async values => {
444 await createCollection(space.name, {
445 name: String(values.name),
446 description: String(values.description).trim() || undefined,
447 icon: String(values.icon).trim() || undefined,
448 parent_dir_id: String(values.parent_dir_id) || undefined,
449402 })
450403 onListsChanged()
451404 }}
Deleted web/src/pages/workspace/model/agent-location.ts web/src/pages/workspace/model/agent-location.ts +0 −122
@@ -1,122 +0,0 @@
1import { useCallback, useMemo } from 'react'
2import { useSearchParams } from 'react-router-dom'
3import {
4 AGENT_VIEW_DEFAULTS,
5 normalizeAgentLayout,
6 type AgentConsoleView,
7} from '@/features/agent-console'
8import { AGENT_STATES, PRIORITIES, SORTS, type AgentPriority, type AgentStateFilter } from '@/entities/agent'
9
10/**
11 * The 智能体 console page state, encoded entirely in the route's query string —
12 * the same contract the issue board uses, so a filtered console is a shareable
13 * link and browser back/forward walks the filters:
14 *
15 * ```text
16 * /app/demo/agent/01a0… → 卡片视图 · 全部状态 · 最新创建
17 * /app/demo/agent/01a0…?layout=list → 列表视图
18 * /app/demo/agent/01a0…?state=executing&sort=updated&q=调研
19 * /app/demo/agent/01a0…?page=3 → 第 3 页
20 * /app/demo/agent/01a0…?task=12 → 任务 #12 详情(含对话与活动)
21 * ```
22 *
23 * Ephemeral state (which modal is open, the composer draft) stays out of the
24 * URL; a task is addressed by its collection-scoped number because that is what
25 * a user reads off the card, and the backend accepts it as a path segment.
26 *
27 * There is no account-level default here (unlike the board's saved view mode):
28 * the console has no preference that would pin a layout, so a plain URL always
29 * renders the same thing.
30 */
31const FILTER_KEYS = ['layout', 'state', 'q', 'priority', 'sort'] as const
32export type AgentFilterKey = (typeof FILTER_KEYS)[number]
33
34const OWNED_KEYS = ['layout', 'state', 'q', 'priority', 'sort', 'page', 'task']
35
36const LAYOUTS = ['cards', 'list'] as const
37const STATES: readonly AgentStateFilter[] = [...AGENT_STATES, 'all']
38
39function pick<T extends string>(raw: string | null, allowed: readonly T[], fallback: T): T {
40 return allowed.includes((raw ?? '') as T) ? (raw as T) : fallback
41}
42
43/** Parse the URL into console state; unknown values fall back to their default. */
44export function readAgentConsoleView(params: URLSearchParams): AgentConsoleView {
45 const rawPage = Number.parseInt(params.get('page') ?? '', 10)
46 return {
47 layout: normalizeAgentLayout(pick(params.get('layout'), LAYOUTS, AGENT_VIEW_DEFAULTS.layout)),
48 state: pick(params.get('state'), STATES, 'all'),
49 q: params.get('q') ?? '',
50 priority: pick(params.get('priority'), PRIORITIES.map(meta => meta.id), '') as AgentPriority | '',
51 sort: pick(params.get('sort'), SORTS.map(meta => meta.id), 'created'),
52 page: Number.isFinite(rawPage) && rawPage > 0 ? rawPage : 1,
53 task: params.get('task') ?? '',
54 }
55}
56
57/** Encode state, dropping every default-valued key so plain URLs stay short. */
58export function writeAgentConsoleView(base: URLSearchParams, state: AgentConsoleView): URLSearchParams {
59 const next = new URLSearchParams(base)
60 for (const key of OWNED_KEYS) next.delete(key)
61 if (state.layout !== AGENT_VIEW_DEFAULTS.layout) next.set('layout', state.layout)
62 if (state.state !== 'all') next.set('state', state.state)
63 if (state.q) next.set('q', state.q)
64 if (state.priority) next.set('priority', state.priority)
65 if (state.sort !== 'created') next.set('sort', state.sort)
66 if (state.page > 1) next.set('page', String(state.page))
67 if (state.task) next.set('task', state.task)
68 return next
69}
70
71/**
72 * A filter change returns to page 1: page 3 of a result set that just changed
73 * shape is a different question, and paging past the end would read as "no
74 * results".
75 */
76export function applyAgentViewPatch(current: AgentConsoleView, updates: Partial<AgentConsoleView>): AgentConsoleView {
77 const merged = { ...current, ...updates }
78 const filterChanged = FILTER_KEYS.some(key => updates[key] !== undefined && updates[key] !== current[key])
79 return filterChanged && merged.page === current.page ? { ...merged, page: 1 } : merged
80}
81
82export interface AgentViewLocation {
83 view: AgentConsoleView
84 /** True while at least one filter narrows the result set. */
85 filtered: boolean
86 patch: (updates: Partial<AgentConsoleView>, options?: { replace?: boolean }) => void
87 clearFilters: () => void
88 openTask: (task: string) => void
89 closeTask: () => void
90 /** Drop every owned param when the collection under the same route changes. */
91 normalize: () => void
92}
93
94export function useAgentViewLocation(): AgentViewLocation {
95 const [params, setParams] = useSearchParams()
96 const view = useMemo(() => readAgentConsoleView(params), [params])
97
98 const patch = useCallback<AgentViewLocation['patch']>(
99 (updates, options) => {
100 setParams(prev => writeAgentConsoleView(prev, applyAgentViewPatch(readAgentConsoleView(prev), updates)), {
101 replace: options?.replace ?? false,
102 preventScrollReset: true,
103 })
104 },
105 [setParams],
106 )
107
108 const clearFilters = useCallback(
109 () => patch({ state: 'all', q: '', priority: '', sort: 'created' }),
110 [patch],
111 )
112 const openTask = useCallback<AgentViewLocation['openTask']>(task => patch({ task }), [patch])
113 const closeTask = useCallback(() => patch({ task: '' }), [patch])
114 const normalize = useCallback(
115 () => setParams(prev => writeAgentConsoleView(prev, { ...AGENT_VIEW_DEFAULTS }), { replace: true, preventScrollReset: true }),
116 [setParams],
117 )
118
119 const filtered = view.state !== 'all' || Boolean(view.q) || Boolean(view.priority) || view.sort !== 'created'
120
121 return { view, filtered, patch, clearFilters, openTask, closeTask, normalize }
122}
No newline at end of file
Modified web/src/pages/workspace/model/prefetch.ts web/src/pages/workspace/model/prefetch.ts +2 −8 View web/src/pages/workspace/model/prefetch.ts
@@ -9,7 +9,6 @@
99 dmsKey,
1010 meKey,
1111 prefetch,
12 spaceAgentsKey,
1312 spaceBoardsKey,
1413 spaceDirKey,
1514 spaceReposKey,
@@ -19,21 +18,19 @@
1918import { prefetchRoomView } from '@/features/chat'
2019import { prefetchRepoView } from '@/features/repo-browse'
2120import { prefetchBoardView } from '@/features/issue-board'
22import { prefetchAgentConsole } from '@/features/agent-console'
2321import { fetchSpaceDir } from '@/features/space-management'
2422import type { RoomBrief } from '@/entities/chat'
2523import {
2624 fetchDms,
2725 fetchMe,
2826 fetchMySpaces,
29 fetchSpaceAgents,
3027 fetchSpaceBoards,
3128 fetchSpaceRepos,
3229 fetchSpaceRooms,
3330} from './shell-api'
34import type { AgentCollectionItem, BoardItem, RepoItem } from './workspace'
31import type { BoardItem, RepoItem } from './workspace'
3532
36export { prefetchRoomView, prefetchRepoView, prefetchBoardView, prefetchAgentConsole }
33export { prefetchRoomView, prefetchRepoView, prefetchBoardView }
3734
3835/**
3936 * Warm the shell's identity and global lists as early as the app boots, in
@@ -55,7 +52,6 @@
5552 void prefetch(spaceDirKey(spaceName), () => fetchSpaceDir(spaceName))
5653 void prefetch(spaceReposKey(spaceName), () => fetchSpaceRepos(spaceName))
5754 void prefetch(spaceBoardsKey(spaceName), () => fetchSpaceBoards(spaceName))
58 void prefetch(spaceAgentsKey(spaceName), () => fetchSpaceAgents(spaceName))
5955}
6056
6157/** How many rooms the idle warm-up may prefetch per space. */
@@ -81,7 +77,6 @@
8177 rooms: RoomBrief[],
8278 repos: RepoItem[],
8379 boards: BoardItem[],
84 agents: AgentCollectionItem[] = [],
8580): void {
8681 onIdle(() => {
8782 const ranked = rooms
@@ -100,6 +95,5 @@
10095 for (const room of ranked) prefetchRoomView(room.id)
10196 if (repos[0]) void prefetchRepoView(spaceName, repos[0].name)
10297 if (boards[0]) prefetchBoardView(spaceName, boards[0].id)
103 if (agents[0]) prefetchAgentConsole(spaceName, agents[0].id)
10498 })
10599}
Modified web/src/pages/workspace/model/route.ts web/src/pages/workspace/model/route.ts +1 −2 View web/src/pages/workspace/model/route.ts
@@ -1,5 +1,5 @@
11/** Main-column variant, taken from the route's static section segment. */
2export type ShellMode = 'chat' | 'board' | 'repo' | 'agent' | 'notify' | 'explore'
2export type ShellMode = 'chat' | 'board' | 'repo' | 'notify' | 'explore'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
55export type RepoTabId =
@@ -20,5 +20,4 @@
2020 roomId?: string
2121 boardId?: string
2222 repoName?: string
23 collectionId?: string
2423}
Modified web/src/pages/workspace/model/shell-api.ts web/src/pages/workspace/model/shell-api.ts +0 −10 View web/src/pages/workspace/model/shell-api.ts
@@ -9,7 +9,6 @@
99import { writeCache } from '@/shared/storage'
1010import { fetchSessionUser } from '@/features/auth'
1111import type {
12 AgentCollectionItem,
1312 BoardItem,
1413 DmItem,
1514 Me,
@@ -63,15 +62,6 @@
6362 pathParams: { space_name: spaceName },
6463 params: { limit: 50 },
6564 }) as unknown as PromiseLike<ApiEnvelope<{ items: BoardItem[] }>>,
66 ).then(p => p?.items ?? [])
67
68// `list_collections` is typed against the generic `Page` schema too.
69export const fetchSpaceAgents = (spaceName: string) =>
70 unwrapApi<{ items: AgentCollectionItem[] }>(
71 Apis.sagent.list_collections({
72 pathParams: { space_name: spaceName },
73 params: { limit: 100 },
74 }) as unknown as PromiseLike<ApiEnvelope<{ items: AgentCollectionItem[] }>>,
7565 ).then(p => p?.items ?? [])
7666
7767/** First page of tag names for the repo toolbar chip. */
Modified web/src/pages/workspace/model/workspace-data.ts web/src/pages/workspace/model/workspace-data.ts +1 −12 View web/src/pages/workspace/model/workspace-data.ts
@@ -3,7 +3,6 @@
33 dmsKey,
44 meKey,
55 repoKey,
6 spaceAgentsKey,
76 spaceBoardsKey,
87 spaceDirKey,
98 spaceReposKey,
@@ -18,7 +17,6 @@
1817 fetchDms,
1918 fetchMe,
2019 fetchMySpaces,
21 fetchSpaceAgents,
2220 fetchSpaceBoards,
2321 fetchSpaceRepos,
2422 fetchSpaceRooms,
@@ -35,7 +33,7 @@
3533 * the network revalidation runs, and the fresh response replaces it live.
3634 */
3735export function useWorkspaceData(
38 { spaceName, roomId, boardId, repoName, collectionId, mode }: ShellRoute,
36 { spaceName, roomId, boardId, repoName, mode }: ShellRoute,
3937 reloadKey = 0,
4038 listsReloadKey = 0,
4139 meReloadKey = 0,
@@ -66,13 +64,6 @@
6664 [spaceName, listsReloadKey],
6765 { cacheKey: scoped ? spaceBoardsKey(spaceName) : null },
6866 )
69 // Smart-agent collections live beside boards in the same dir tree, so the
70 // sidebar needs both lists to decide what a dir node opens.
71 const agents = useAsync(
72 () => (scoped ? fetchSpaceAgents(spaceName) : Promise.resolve([])),
73 [spaceName, listsReloadKey],
74 { cacheKey: scoped ? spaceAgentsKey(spaceName) : null },
75 )
7667 const repoRef = mode === 'repo' && repoName ? `${spaceName}/${repoName}` : ''
7768 const tags = useAsync(
7869 () => (repoRef ? fetchTags(spaceName, repoName!) : Promise.resolve([])),
@@ -106,14 +97,12 @@
10697 dms,
10798 repos,
10899 boards,
109 agents,
110100 tags,
111101 repoRef,
112102 currentSpace: spaces.data?.find(({ space }) => space.name === spaceName)?.space,
113103 room: rooms.data?.find(r => r.id === roomId),
114104 dm: dms.data?.find(d => d.id === roomId),
115105 board: boards.data?.find(b => b.id === boardId),
116 agentCollection: agents.data?.find(a => a.id === collectionId),
117106 repo: repos.data?.find(r => r.name === repoName),
118107 }
119108}
Modified web/src/pages/workspace/model/workspace-route.ts web/src/pages/workspace/model/workspace-route.ts +2 −5 View web/src/pages/workspace/model/workspace-route.ts
@@ -2,7 +2,7 @@
22import type { ShellMode, ShellRoute } from './route'
33
44/**
5 * URL shape is `/app/{space_name}/{chat|board|repo|agent}/{id?}` (plus the personal
5 * URL shape is `/app/{space_name}/{chat|board|repo}/{id?}` (plus the personal
66 * `/app/@me/notify` inbox). The section is a static route segment, so it is
77 * read off the pathname, and so is every dynamic segment: the workspace is a
88 * single layout route (see `app/routes/router.tsx`) whose children only declare
@@ -31,9 +31,7 @@
3131 ? 'board'
3232 : section === 'repo'
3333 ? 'repo'
34 : section === 'agent'
35 ? 'agent'
36 : 'chat'
34 : 'chat'
3735 // `/app/@me…` routes carry no :spaceName param; name the personal scope so
3836 // data loading and the rail can treat it like any other scope.
3937 const personal = pathname === '/app/@me' || pathname.startsWith('/app/@me/')
@@ -44,6 +42,5 @@
4442 roomId: section === 'chat' ? decode(rawId) : undefined,
4543 boardId: section === 'board' ? decode(rawId) : undefined,
4644 repoName: section === 'repo' ? decode(rawId) : undefined,
47 collectionId: section === 'agent' ? decode(rawId) : undefined,
4845 }
4946}
Modified web/src/pages/workspace/model/workspace.ts web/src/pages/workspace/model/workspace.ts +0 −3 View web/src/pages/workspace/model/workspace.ts
@@ -5,7 +5,6 @@
55import type { DmBrief, RoomBrief } from '@/entities/chat'
66import type {
77 AppResponseData_BoardDto,
8 AppResponseData_CollectionDto,
98 AppResponseData_MeResp,
109 AppResponseData_InviteDto,
1110 AppResponseData_SpaceDto,
@@ -24,8 +23,6 @@
2423 NonNullable<AppResponseData_Page_RepoSummaryDto['data']>['items']
2524>[number]
2625export type BoardItem = NonNullable<AppResponseData_BoardDto['data']>
27/** Smart-agent collection row: same two-layer shape as a board, own API. */
28export type AgentCollectionItem = NonNullable<AppResponseData_CollectionDto['data']>
2926export type Me = NonNullable<AppResponseData_MeResp['data']>
3027export type SpaceDto = NonNullable<AppResponseData_SpaceDto['data']>
3128export type Invite = NonNullable<AppResponseData_InviteDto['data']>
Deleted web/src/pages/workspace/ui/agent/agent-view.tsx web/src/pages/workspace/ui/agent/agent-view.tsx +0 −202
@@ -1,202 +0,0 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { useNavigate } from 'react-router-dom'
3import { Button, Modal } from '@/shared/ui'
4import { t as tr, useT } from '@/shared/i18n'
5import {
6 AgentActions,
7 AgentConsole,
8 AgentDetail,
9 AgentFilters,
10 AgentFormModal,
11 buildAgentQuery,
12 CollectionManageModal,
13 useAgentConsoleData,
14} from '@/features/agent-console'
15import { AGENT_CREATE_ENABLED, deleteTask, type AgentCollection, type AgentTask } from '@/entities/agent'
16import { useAgentViewLocation } from '../../model/agent-location'
17import { AgentLayout } from '../layout/agent-layout'
18
19export interface AgentViewProps {
20 spaceName: string
21 collectionId: string
22 /** Row from the sidebar's collection list — titles the frame before the fetch lands. */
23 collectionHint?: AgentCollection
24 shell: { rail?: React.ReactNode; sideHeader?: React.ReactNode; navPinned?: React.ReactNode; nav?: React.ReactNode; sideFooter?: React.ReactNode }
25 /** Sidebar counts come from the shell's own query. */
26 onCountsChanged: () => void
27}
28
29/** Distinguish "wrong link" from "no access" from "backend said no". */
30function collectionGoneMessage(code: number | undefined): string {
31 if (code === 404) return tr('agent.console.notFound')
32 if (code === 403) return tr('agent.console.forbidden')
33 if (code === 401) return tr('error.status.401')
34 return tr('agent.console.loadFailed')
35}
36
37/**
38 * Route-level 智能体 console page. The URL owns the view state (see
39 * `model/agent-location`), so this component keeps no mirror of it: it derives
40 * the request, hands data + callbacks to the feature views and holds the two
41 * dialogs (task form / collection management) as single instances.
42 */
43export function AgentView({ spaceName, collectionId, collectionHint, shell, onCountsChanged }: AgentViewProps) {
44 const { t } = useT()
45 const navigate = useNavigate()
46 const loc = useAgentViewLocation()
47 const { view } = loc
48 const [reload, setReload] = useState(0)
49 const [editing, setEditing] = useState<AgentTask | null>(null)
50 const [creating, setCreating] = useState(false)
51 const [managing, setManaging] = useState(false)
52 const [deleting, setDeleting] = useState<AgentTask | null>(null)
53 const [busy, setBusy] = useState(false)
54
55 const ref = useMemo(() => ({ space: spaceName, collection: collectionId }), [spaceName, collectionId])
56 const query = useMemo(() => buildAgentQuery(view), [view])
57 const data = useAgentConsoleData(ref, query, view.task || null, reload)
58
59 const onChanged = useCallback(() => {
60 setReload(key => key + 1)
61 onCountsChanged()
62 }, [onCountsChanged])
63
64 // Switching collection inside the same route component must not carry the
65 // previous collection's filters or `?task=` over; a fresh mount keeps the URL
66 // as typed.
67 const lastCollection = useRef<string | null>(null)
68 useEffect(() => {
69 if (lastCollection.current !== null && lastCollection.current !== collectionId) loc.normalize()
70 lastCollection.current = collectionId
71 // eslint-disable-next-line react-hooks/exhaustive-deps
72 }, [collectionId])
73
74 const collection = data.collection ?? collectionHint
75 const collectionFailure = data.collectionFailure
76
77 if (!collectionId) {
78 return (
79 <AgentLayout {...shell} title={t('agent.console.title')}>
80 <div className="w-full rounded-lg border-2 border-dashed border-shell-hairline px-4 py-16 text-center text-[13px] text-shell-ink-muted">
81 {t('agent.console.pick')}
82 </div>
83 </AgentLayout>
84 )
85 }
86
87 // The collection is gone (deleted elsewhere) or was never visible: nothing
88 // below it can work, so replace the whole content column.
89 if (collectionFailure && !collection) {
90 return (
91 <AgentLayout {...shell} title={t('agent.console.title')}>
92 <div className="flex w-full flex-col items-center gap-3 rounded-lg border-2 border-shell-hairline bg-shell-surface px-4 py-16 text-center">
93 <p className="text-[15px] font-semibold">{collectionGoneMessage(collectionFailure.code)}</p>
94 <p className="max-w-sm text-[12.5px] text-shell-ink-muted">
95 {collectionFailure.message ?? t('agent.console.deletedHint')}
96 </p>
97 <div className="flex items-center gap-2">
98 <Button variant="secondary" onClick={() => setReload(key => key + 1)}>
99 {t('common.action.retry')}
100 </Button>
101 <Button onClick={() => navigate(`/app/${encodeURIComponent(spaceName)}`)}>
102 {t('agent.console.return')}
103 </Button>
104 </div>
105 </div>
106 </AgentLayout>
107 )
108 }
109
110 async function confirmDelete() {
111 if (!deleting) return
112 setBusy(true)
113 try {
114 await deleteTask(ref, deleting.id)
115 setDeleting(null)
116 loc.closeTask()
117 onChanged()
118 } finally {
119 setBusy(false)
120 }
121 }
122
123 return (
124 <>
125 <AgentLayout
126 {...shell}
127 title={collection?.name ?? t('agent.console.title')}
128 topic={collection?.description ?? undefined}
129 toolbar={<AgentFilters view={view} onFilter={loc.patch} onClear={loc.clearFilters} />}
130 toolbarActions={
131 <AgentActions
132 view={view}
133 onFilter={loc.patch}
134 onCreate={AGENT_CREATE_ENABLED ? () => setCreating(true) : undefined}
135 />
136 }
137 >
138 {view.task ? (
139 <AgentDetail
140 agentRef={ref}
141 data={data}
142 onEdit={() => setEditing(data.task ?? null)}
143 onChanged={onChanged}
144 onClose={loc.closeTask}
145 onDelete={() => setDeleting(data.task ?? null)}
146 />
147 ) : (
148 <AgentConsole
149 data={data}
150 view={view}
151 onFilter={loc.patch}
152 onOpenTask={task => loc.openTask(String(task.number))}
153 onCreate={AGENT_CREATE_ENABLED ? () => setCreating(true) : undefined}
154 onRetry={() => setReload(key => key + 1)}
155 />
156 )}
157 </AgentLayout>
158
159 <AgentFormModal
160 agentRef={ref}
161 task={editing}
162 open={creating || editing !== null}
163 onClose={() => {
164 setCreating(false)
165 setEditing(null)
166 }}
167 onSaved={saved => {
168 const wasEditing = editing !== null
169 setCreating(false)
170 setEditing(null)
171 onChanged()
172 // A new task opens immediately: the author must see what they wrote.
173 if (!wasEditing) loc.openTask(String(saved.number))
174 }}
175 />
176
177 <CollectionManageModal
178 agentRef={ref}
179 collection={collection}
180 open={managing}
181 onClose={() => setManaging(false)}
182 onSaved={onChanged}
183 onDeleted={() => {
184 setManaging(false)
185 navigate(`/app/${encodeURIComponent(spaceName)}`)
186 }}
187 />
188
189 <Modal open={deleting !== null} onClose={() => setDeleting(null)} title={t('agent.console.task.delete')} width="max-w-[440px]">
190 <p className="text-[13px] leading-[1.6]">{t('agent.console.task.deleteConfirm')}</p>
191 <div className="flex justify-end gap-2 pt-4">
192 <Button variant="secondary" onClick={() => setDeleting(null)}>
193 {t('common.action.cancel')}
194 </Button>
195 <Button disabled={busy} onClick={() => void confirmDelete()}>
196 {t('common.action.delete')}
197 </Button>
198 </div>
199 </Modal>
200 </>
201 )
202}
No newline at end of file
Deleted web/src/pages/workspace/ui/layout/agent-layout.tsx web/src/pages/workspace/ui/layout/agent-layout.tsx +0 −46
@@ -1,46 +0,0 @@
1import type { ReactNode } from 'react'
2import { Bot } from 'lucide-react'
3import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
4
5/**
6 * 智能体 console main-column layout: top bar + toolbar row + a single
7 * vertically scrolling pane. Unlike the issue board there is no kanban track —
8 * a task is a state, not a column — so both variants share the same shell.
9 */
10
11export interface AgentLayoutProps extends Pick<AppShellProps, 'rail' | 'sideHeader' | 'navPinned' | 'nav' | 'sideFooter'> {
12 title?: ReactNode
13 topic?: ReactNode
14 actions?: ReactNode
15 /** Toolbar left segment (filters). */
16 toolbar?: ReactNode
17 /** Toolbar right segment (layout switch + primary action). */
18 toolbarActions?: ReactNode
19 children?: ReactNode
20}
21
22export function AgentLayout({
23 title,
24 topic,
25 actions,
26 toolbar,
27 toolbarActions,
28 children,
29 ...shell
30}: AgentLayoutProps) {
31 return (
32 <AppShell {...shell}>
33 <ShellTopBar
34 icon={<Bot className="h-[18px] w-[18px] shrink-0 text-shell-ink-muted" />}
35 title={title}
36 topic={topic}
37 actions={actions}
38 />
39 <div className="flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b border-shell-hairline px-4 py-2">
40 {toolbar}
41 <div className="ml-auto flex items-center gap-3">{toolbarActions}</div>
42 </div>
43 <div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6 pt-3">{children}</div>
44 </AppShell>
45 )
46}
No newline at end of file
Modified web/src/pages/workspace/ui/sidebar-nav.tsx web/src/pages/workspace/ui/sidebar-nav.tsx +5 −43 View web/src/pages/workspace/ui/sidebar-nav.tsx
@@ -2,7 +2,6 @@
22import { Link } from 'react-router-dom'
33import {
44 AtSign,
5 Bot,
65 ChevronDown,
76 ChevronRight,
87 File,
@@ -19,26 +18,23 @@
1918import { getVoiceSpaceStates, isVoiceRoom } from '@/entities/chat'
2019import { betweenPositions, STRIDE } from '../model/nav-order'
2120import {
22 prefetchAgentConsole,
2321 prefetchBoardView,
2422 prefetchRepoView,
2523 prefetchRoomView,
2624} from '../model/prefetch'
27import type { AgentCollectionItem, BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
25import type { BoardItem, DirNode, DmItem, RepoItem, RoomBrief } from '../model/workspace'
2826
2927export interface SidebarNavProps {
3028 spaceName: string
31 /** Space directory tree (folders + repo/board/collection placeholders). */
29 /** Space directory tree (folders + repo/board placeholders). */
3230 dirs: DirNode[]
3331 rooms: RoomBrief[]
3432 dms: DmItem[]
3533 repos: RepoItem[]
3634 boards: BoardItem[]
37 collections: AgentCollectionItem[]
3835 activeRoomId?: string
3936 activeBoardId?: string
4037 activeRepoName?: string
41 activeCollectionId?: string
4238 /** Both move endpoints require the space's `manage_channels` permission. */
4339 canOrganize?: boolean
4440 /** A move was persisted: the shell refetches the directory tree and rooms. */
@@ -46,7 +42,7 @@
4642}
4743
4844/** What a row is, after mapping the directory tree onto the space's assets. */
49type RowKind = 'folder' | 'repo' | 'board' | 'agent' | 'file' | 'room' | 'dm'
45type RowKind = 'folder' | 'repo' | 'board' | 'file' | 'room' | 'dm'
5046
5147/** One row of the view model, positioned inside its (effective) parent level. */
5248interface View {
@@ -63,7 +59,7 @@
6359 unread: number
6460 favorite: boolean
6561 voice: boolean
66 /** Board (or collection) id behind a row, so the active one can be matched exactly. */
62 /** Board id behind a row, so the active one can be matched exactly. */
6763 assetId?: string
6864 /** Peer of a private-message row: its avatar replaces the channel icon. */
6965 peer?: { avatar_url?: string; display_name?: string; username?: string } | null
@@ -92,11 +88,9 @@
9288 dms,
9389 repos,
9490 boards,
95 collections,
9691 activeRoomId,
9792 activeBoardId,
9893 activeRepoName,
99 activeCollectionId,
10094 canOrganize = false,
10195 onChanged,
10296}: SidebarNavProps) {
@@ -167,7 +161,6 @@
167161 const dirIds = new Set(flat.map(({ node }) => node.id))
168162 const repoByName = new Map(repos.map(repo => [repo.name.toLowerCase(), repo]))
169163 const boardByDir = new Map(boards.map(board => [board.dir_id, board]))
170 const collectionByDir = new Map(collections.map(collection => [collection.dir_id, collection]))
171164 const visibleRooms = rooms.filter(
172165 room => room.kind !== 'Category' && room.kind !== 'Thread' && !room.hidden,
173166 )
@@ -181,7 +174,6 @@
181174
182175 const coveredRepoIds = new Set<string>()
183176 const coveredBoardIds = new Set<string>()
184 const coveredCollectionIds = new Set<string>()
185177 for (const { node, parent } of flat) {
186178 const key = `d:${node.id}`
187179 const edit = edits[key]
@@ -191,7 +183,6 @@
191183 let href: string | null = null
192184 let unread = 0
193185 const board = boardByDir.get(node.id)
194 const collection = collectionByDir.get(node.id)
195186 const repo = repoByName.get(node.name.toLowerCase())
196187 if (node.kind === 'Dir') kind = 'folder'
197188 else if (board) {
@@ -199,11 +190,6 @@
199190 href = `/app/${spacePath}/board/${encodeURIComponent(board.id)}`
200191 unread = board.open_issues_count
201192 coveredBoardIds.add(board.id)
202 } else if (collection) {
203 kind = 'agent'
204 href = `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`
205 unread = collection.running_tasks
206 coveredCollectionIds.add(collection.id)
207193 } else if (repo) {
208194 kind = 'repo'
209195 href = `/app/${spacePath}/repo/${encodeURIComponent(repo.name)}`
@@ -221,7 +207,7 @@
221207 unread,
222208 favorite: false,
223209 voice: false,
224 assetId: board?.id ?? collection?.id,
210 assetId: board?.id,
225211 })
226212 }
227213 for (const room of visibleRooms) {
@@ -294,24 +280,6 @@
294280 })),
295281 )
296282 appendTail(
297 collections
298 .filter(collection => !coveredCollectionIds.has(collection.id))
299 .map(collection => ({
300 key: `x:agent-${collection.id}`,
301 type: 'dir' as const,
302 id: '',
303 kind: 'agent' as RowKind,
304 label: collection.name,
305 href: `/app/${spacePath}/agent/${encodeURIComponent(collection.id)}`,
306 parentId: null,
307 position: 0,
308 unread: collection.running_tasks,
309 favorite: false,
310 voice: false,
311 assetId: collection.id,
312 })),
313 )
314 appendTail(
315283 dms.map(dm => ({
316284 key: `m:${dm.id}`,
317285 // No move endpoint exists for a DM, and `id` stays empty so `movable`
@@ -475,8 +443,6 @@
475443 void prefetchRepoView(spaceName, view.label)
476444 } else if (view.kind === 'board' && view.assetId) {
477445 prefetchBoardView(spaceName, view.assetId)
478 } else if (view.kind === 'agent' && view.assetId) {
479 prefetchAgentConsole(spaceName, view.assetId)
480446 }
481447 },
482448 [spaceName],
@@ -509,9 +475,6 @@
509475 active={
510476 (view.kind === 'room' && view.id === activeRoomId) ||
511477 (view.kind === 'board' && Boolean(activeBoardId) && view.assetId === activeBoardId) ||
512 (view.kind === 'agent' &&
513 Boolean(activeCollectionId) &&
514 view.assetId === activeCollectionId) ||
515478 (view.kind === 'repo' && view.label === activeRepoName)
516479 }
517480 dragging={dragKey === view.key}
@@ -590,7 +553,6 @@
590553 dm: <AtSign className="h-4 w-4" />,
591554 repo: <FolderGit2 className="h-4 w-4" />,
592555 board: <SquareKanban className="h-4 w-4" />,
593 agent: <Bot className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
594556 file: <File className="h-4 w-4 shrink-0 text-shell-ink-muted" aria-hidden />,
595557}
596558
Modified web/src/pages/workspace/ui/workspace-shell.tsx web/src/pages/workspace/ui/workspace-shell.tsx +13 −39 View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -2,7 +2,6 @@
22import { Navigate, useNavigate, useSearchParams } from 'react-router-dom'
33import { Loader2 } from 'lucide-react'
44import { BoardView } from './board/board-view'
5import { AgentView } from './agent/agent-view'
65import { ChatLayout } from './layout/chat-layout'
76import { ChatView } from './chat/chat-view'
87import { VoiceCallBar } from '@/features/chat'
@@ -36,7 +35,7 @@
3635 // notification points here); the param is consumed on open so a later
3736 // manual open starts from the default section again.
3837 const [searchParams, setSearchParams] = useSearchParams()
39 const { mode, spaceName, roomId, boardId, repoName, collectionId } = route
38 const { mode, spaceName, roomId, boardId, repoName } = route
4039 const exploring = mode === 'explore'
4140 const [creatingSpace, setCreatingSpace] = useState(false)
4241 const [spaceReload, setSpaceReload] = useState(0)
@@ -49,8 +48,8 @@
4948 const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined)
5049 const badgeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
5150 const {
52 me, spaces, rooms, dirs, dms, repos, boards, agents,
53 room, dm, board, agentCollection,
51 me, spaces, rooms, dirs, dms, repos, boards,
52 room, dm, board,
5453 } = useWorkspaceData(route, spaceReload, listsReload, meReload)
5554 const requestedSection = searchParams.get('settings')
5655 const consumeSectionParam = useCallback(() => {
@@ -90,11 +89,11 @@
9089 const idleWarmed = useRef<string | null>(null)
9190 useEffect(() => {
9291 if (exploring || spaceName === '@me' || idleWarmed.current === spaceName) return
93 if (rooms.loading || repos.loading || boards.loading || agents.loading) return
94 if (!rooms.data || !repos.data || !boards.data || !agents.data) return
92 if (rooms.loading || repos.loading || boards.loading) return
93 if (!rooms.data || !repos.data || !boards.data) return
9594 idleWarmed.current = spaceName
96 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data, agents.data)
97 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data, agents.loading, agents.data])
95 prefetchLikelyTargets(spaceName, rooms.data, repos.data, boards.data)
96 }, [exploring, spaceName, rooms.loading, rooms.data, repos.loading, repos.data, boards.loading, boards.data])
9897
9998 // A `?settings=<section>` link (join-request notifications) opens the
10099 // overlay for the space in scope. Without the permission that owns the
@@ -127,14 +126,12 @@
127126 ? t('shell.titleRepo', { repo: repoName, space: spaceName })
128127 : mode === 'board' && boardId
129128 ? t('shell.titleBoard', { board: board?.name || boardId, space: spaceName })
130 : mode === 'agent' && collectionId
131 ? t('shell.titleAgent', { collection: agentCollection?.name || collectionId, space: spaceName })
132 : roomId
133 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
134 : spaceName && spaceName !== '@me'
135 ? t('shell.titleSpace', { space: spaceName })
136 : base
137 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name, collectionId, agentCollection?.name])
129 : roomId
130 ? t('shell.titleChat', { room: room?.name || dm?.name || roomId, space: spaceName })
131 : spaceName && spaceName !== '@me'
132 ? t('shell.titleSpace', { space: spaceName })
133 : base
134 }, [t, mode, spaceName, repoName, boardId, board?.name, roomId, room?.name, dm?.name])
138135
139136 if (me.error instanceof ApiFailure && me.error.code === 401) {
140137 return <Navigate to="/auth/login" replace />
@@ -207,11 +204,9 @@
207204 dms={dms.data ?? []}
208205 repos={repos.data ?? []}
209206 boards={boards.data ?? []}
210 collections={agents.data ?? []}
211207 activeRoomId={roomId}
212208 activeBoardId={boardId}
213209 activeRepoName={repoName}
214 activeCollectionId={collectionId}
215210 canOrganize={canOrganize}
216211 onChanged={() => setListsReload(key => key + 1)}
217212 />
@@ -304,27 +299,6 @@
304299 boardId={boardId ?? ''}
305300 boardHint={board}
306301 viewerId={me.data?.user.user_id}
307 shell={shell}
308 onCountsChanged={() => setListsReload(key => key + 1)}
309 />
310 {createSpaceModal}
311 {settingsModal}
312 {spaceSettingsModal}
313 <NotifyToaster />
314
315 <VoiceCallBar />
316 <GlobalSearchCommand scope={searchScope} />
317 </>
318 )
319 }
320
321 if (mode === 'agent') {
322 return (
323 <>
324 <AgentView
325 spaceName={spaceName}
326 collectionId={collectionId ?? ''}
327 collectionHint={agentCollection}
328302 shell={shell}
329303 onCountsChanged={() => setListsReload(key => key + 1)}
330304 />
Modified web/src/shared/api/apiDefinitions.ts web/src/shared/api/apiDefinitions.ts +0 −48 View web/src/shared/api/apiDefinitions.ts
@@ -100,54 +100,6 @@
100100 * **Do not edit the file manually.**
101101 */
102102export default {
103 'sagent.list_collections': ['GET', '/agents/{space_name}/collections'],
104 'sagent.create_collection': ['POST', '/agents/{space_name}/collections'],
105 'sagent.get_collection': ['GET', '/agents/{space_name}/collections/{collection_id}'],
106 'sagent.delete_collection': ['DELETE', '/agents/{space_name}/collections/{collection_id}'],
107 'sagent.update_collection': ['PATCH', '/agents/{space_name}/collections/{collection_id}'],
108 'sagent.list_labels': ['GET', '/agents/{space_name}/collections/{collection_id}/labels'],
109 'sagent.create_label': ['POST', '/agents/{space_name}/collections/{collection_id}/labels'],
110 'sagent.get_label': ['GET', '/agents/{space_name}/collections/{collection_id}/labels/{label_id}'],
111 'sagent.delete_label': ['DELETE', '/agents/{space_name}/collections/{collection_id}/labels/{label_id}'],
112 'sagent.update_label': ['PATCH', '/agents/{space_name}/collections/{collection_id}/labels/{label_id}'],
113 'sagent.list_tasks': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks'],
114 'sagent.create_task': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks'],
115 'sagent.get_task': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}'],
116 'sagent.delete_task': ['DELETE', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}'],
117 'sagent.update_task': ['PATCH', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}'],
118 'sagent.list_artifacts': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts'],
119 'sagent.get_artifact': [
120 'GET',
121 '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/artifacts/{artifact_id}'
122 ],
123 'sagent.cancel_task': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/cancel'],
124 'sagent.close_task': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/close'],
125 'sagent.list_events': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/events'],
126 'sagent.list_task_labels': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels'],
127 'sagent.bind_label': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels/{label_id}'],
128 'sagent.unbind_label': [
129 'DELETE',
130 '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/labels/{label_id}'
131 ],
132 'sagent.list_members': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members'],
133 'sagent.create_member': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members'],
134 'sagent.delete_member': [
135 'DELETE',
136 '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/members/{user_id}'
137 ],
138 'sagent.list_messages': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/messages'],
139 'sagent.get_read_state': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/read-state'],
140 'sagent.update_read_state': ['PUT', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/read-state'],
141 'sagent.reopen_task': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/reopen'],
142 'sagent.list_repo_relations': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos'],
143 'sagent.create_repo_relation': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos'],
144 'sagent.delete_repo_relation': [
145 'DELETE',
146 '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/repos/{rel_id}'
147 ],
148 'sagent.start_task': ['POST', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/start'],
149 'sagent.get_task_token': ['GET', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/token'],
150 'sagent.upsert_watch': ['PUT', '/agents/{space_name}/collections/{collection_id}/tasks/{task_id}/watch'],
151103 'ai.list_ai_languages': ['GET', '/ai/languages'],
152104 'auth.totp_disable': ['POST', '/auth/2fa/disable'],
153105 'auth.totp_enable': ['POST', '/auth/2fa/enable'],
Modified web/src/shared/api/globals.d.ts web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Deleted web/src/shared/i18n/messages/en/agent.ts web/src/shared/i18n/messages/en/agent.ts +0 −144
@@ -1,144 +0,0 @@
1/** Sandbox-agent console (agent collections / agent tasks). */
2export const agent = {
3 'agent.console.title': 'Agents',
4 'agent.console.pick': 'Pick an agent collection',
5 'agent.console.notFound': 'This agent collection does not exist',
6 'agent.console.forbidden': 'You cannot view this agent collection',
7 'agent.console.loadFailed': 'Failed to load the agent collection',
8 'agent.console.deletedHint': 'It may have been deleted or moved elsewhere.',
9 'agent.console.return': 'Back to the space',
10 'agent.console.empty': 'No tasks yet',
11 'agent.console.emptyNote': 'Create a task and hand it to the sandbox agent.',
12 'agent.console.emptyFiltered': 'No tasks match these filters',
13 'agent.console.new': 'New task',
14 'agent.console.manage': 'Manage collection',
15 'agent.console.viewCards': 'Cards',
16 'agent.console.viewList': 'List',
17 'agent.console.filter.state': 'State',
18 'agent.console.filter.priority': 'Priority',
19 'agent.console.filter.sort': 'Sort',
20 'agent.console.filter.search': 'Search tasks',
21 'agent.console.filter.reset': 'Reset filters',
22 'agent.console.errorList': 'task list',
23 'agent.console.errorTask': 'task detail',
24 'agent.console.pageOutOfRange': 'Page {page} is out of range: {total} tasks, {limit} per page.',
25 'agent.console.backToFirst': 'Back to page 1',
26 'agent.console.prev': 'Previous',
27 'agent.console.next': 'Next',
28 'agent.console.pager': 'Page {page} / {pages} · {total} tasks',
29 'agent.console.task.number': 'No.',
30 'agent.console.task.title': 'Title',
31 'agent.console.task.state': 'State',
32 'agent.console.task.priority': 'Priority',
33 'agent.console.task.createdAt': 'Created',
34 'agent.console.task.actions': 'Actions',
35 'agent.console.task.start': 'Start',
36 'agent.console.task.cancel': 'Cancel run',
37 'agent.console.task.close': 'Close task',
38 'agent.console.task.reopen': 'Reopen task',
39 'agent.console.task.delete': 'Delete task',
40 'agent.console.task.deleteConfirm': 'The task and its conversation and activity records are deleted with it, and this cannot be undone.',
41 'agent.console.task.edit': 'Edit task',
42 'agent.console.task.watch': 'Watch task',
43 'agent.console.task.back': 'Back to list',
44 'agent.console.task.body': 'Task brief',
45 'agent.console.task.bodyFrozen': 'The task is running; its brief is frozen.',
46 'agent.console.task.noBody': 'No task brief.',
47 'agent.console.task.conversation': 'Conversation',
48 'agent.console.task.noMessages': 'No messages yet.',
49 'agent.console.task.sendFailed': 'Could not send',
50 'agent.console.stream.idle': 'Not connected',
51 'agent.console.stream.connecting': 'Connecting',
52 'agent.console.stream.live': 'Live',
53 'agent.console.stream.reconnecting': 'Reconnecting',
54 'agent.console.stream.expired': 'Token expired',
55 'agent.console.stream.error': 'Disconnected',
56 'agent.console.stream.stopped': 'Closed',
57 'agent.console.task.activity': 'Activity',
58 'agent.console.task.noEvents': 'No activity yet.',
59 'agent.console.task.composerPlaceholder': 'Send the agent a message…',
60 'agent.console.task.send': 'Send',
61 'agent.console.task.startHint': 'This task has not started; use “Start” to hand the brief to the agent.',
62 'agent.console.form.createTitle': 'New task',
63 'agent.console.form.editTitle': 'Edit task',
64 'agent.console.form.title': 'Title',
65 'agent.console.form.titlePlaceholder': 'One line on what this task should do',
66 'agent.console.form.titleRequired': 'Title is required',
67 'agent.console.form.description': 'Summary',
68 'agent.console.form.body': 'Task brief',
69 'agent.console.form.priority': 'Priority',
70 'agent.console.form.dueAt': 'Due date',
71 'agent.console.manage.deleteConfirm': 'Its tasks are deleted with it, and this cannot be undone.',
72 'agent.console.manage.stats': '{running} running · {total} tasks in total',
73 'agent.console.actionFailed': 'The action failed',
74 'agent.console.streamLost': 'Live connection closed',
75 'agent.console.reconnecting': 'Live connection dropped, reconnecting…',
76 'agent.state.draft': 'Draft',
77 'agent.state.pending': 'Queued',
78 'agent.state.planning': 'Planning',
79 'agent.state.executing': 'Executing',
80 'agent.state.waiting': 'Waiting',
81 'agent.state.succeeded': 'Succeeded',
82 'agent.state.failed': 'Failed',
83 'agent.state.canceled': 'Canceled',
84 'agent.state.closed': 'Closed',
85 'agent.state.all': 'All',
86 'agent.sort.created': 'Newest',
87 'agent.sort.updated': 'Recently updated',
88 'agent.sort.priority': 'Priority',
89 'agent.sort.number': 'Number',
90 'agent.role.user': 'User',
91 'agent.role.agent': 'Agent',
92 'agent.role.system': 'System',
93 'agent.role.tool': 'Tool',
94 'agent.watch.watching': 'Watching',
95 'agent.watch.unwatched': 'Not watching',
96 'agent.watch.ignore': 'Ignoring',
97 'agent.event.created': 'created the task',
98 'agent.event.title_updated': 'changed the title',
99 'agent.event.body_updated': 'changed the task brief',
100 'agent.event.started': 'started the run',
101 'agent.event.state_changed': 'state changed',
102 'agent.event.message_added': 'new message',
103 'agent.event.run_event': 'run event',
104 'agent.event.run_failed': 'run failed',
105 'agent.event.artifact_added': 'produced an artifact',
106 'agent.event.subscriber_lost': 'live subscription dropped',
107 'agent.event.assigned': 'assigned a member',
108 'agent.event.unassigned': 'removed a member',
109 'agent.event.label_added': 'added a label',
110 'agent.event.label_removed': 'removed a label',
111 'agent.event.repo_relation_added': 'linked a repository',
112 'agent.event.repo_relation_removed': 'unlinked a repository',
113 'agent.event.closed': 'closed the task',
114 'agent.event.reopened': 'reopened the task',
115 'agent.event.unknown': 'Unknown activity',
116 'agent.event.fallback': 'Activity: {type}',
117 'agent.labels.title': 'Labels',
118 'agent.labels.empty': 'No labels yet.',
119 'agent.labels.add': 'Add',
120 'agent.labels.new': 'New label',
121 'agent.labels.createTitle': 'New label',
122 'agent.labels.name': 'Name',
123 'agent.labels.color': 'Colour',
124 'agent.labels.nameRequired': 'Enter a label name',
125 'agent.labels.unbind': 'Remove label',
126 'agent.member.title': 'Members',
127 'agent.member.empty': 'No members yet.',
128 'agent.member.add': 'Add',
129 'agent.member.remove': 'Remove member',
130 'agent.member.assignee': 'Assignee',
131 'agent.member.mentioned': 'Mentioned',
132 'agent.member.participant': 'Participant',
133 'agent.artifact.title': 'Artifacts',
134 'agent.artifact.empty': 'No artifacts yet.',
135 'agent.artifact.download': 'Download',
136 'agent.artifact.missingLink': 'Download link unavailable',
137 'agent.read.unread': '{count} unread',
138 'space.menu.createAgent': 'New agent collection',
139 'space.agent.create.title': 'Create agent collection',
140 'space.agent.nameField': 'Name',
141 'space.agent.description': 'Description',
142 'space.agent.icon': 'Icon',
143 'shell.titleAgent': '{collection} · {space} · GitDataAI',
144}
No newline at end of file
Modified web/src/shared/i18n/messages/en/index.ts web/src/shared/i18n/messages/en/index.ts +0 −2 View web/src/shared/i18n/messages/en/index.ts
@@ -1,5 +1,4 @@
11import type { MessageKey } from '../zh-CN'
2import { agent } from './agent'
32import { auth } from './auth'
43import { chat } from './chat'
54import { chatComposer } from './chatComposer'
@@ -24,7 +23,6 @@
2423 * compile error rather than a silent runtime fallback.
2524 */
2625export const en: Record<MessageKey, string> = {
27 ...agent,
2826 ...common,
2927 ...auth,
3028 ...errors,
Deleted web/src/shared/i18n/messages/zh-CN/agent.ts web/src/shared/i18n/messages/zh-CN/agent.ts +0 −144
@@ -1,144 +0,0 @@
1/** Sandbox-agent console (智能体集 / 智能体任务). */
2export const agent = {
3 'agent.console.title': '智能体',
4 'agent.console.pick': '选择一个智能体集',
5 'agent.console.notFound': '这个智能体集不存在',
6 'agent.console.forbidden': '你没有权限查看这个智能体集',
7 'agent.console.loadFailed': '智能体集加载失败',
8 'agent.console.deletedHint': '它可能已被删除,或改到了别处。',
9 'agent.console.return': '返回空间首页',
10 'agent.console.empty': '还没有任务',
11 'agent.console.emptyNote': '创建一个任务,交给沙箱智能体执行。',
12 'agent.console.emptyFiltered': '没有符合条件的任务',
13 'agent.console.new': '新建任务',
14 'agent.console.manage': '管理智能体集',
15 'agent.console.viewCards': '卡片',
16 'agent.console.viewList': '列表',
17 'agent.console.filter.state': '状态',
18 'agent.console.filter.priority': '优先级',
19 'agent.console.filter.sort': '排序',
20 'agent.console.filter.search': '搜索任务',
21 'agent.console.filter.reset': '重置筛选',
22 'agent.console.errorList': '任务列表',
23 'agent.console.errorTask': '任务详情',
24 'agent.console.pageOutOfRange': '第 {page} 页超出范围:共 {total} 个任务,每页 {limit} 条。',
25 'agent.console.backToFirst': '回到第 1 页',
26 'agent.console.prev': '上一页',
27 'agent.console.next': '下一页',
28 'agent.console.pager': '第 {page} / {pages} 页 · 共 {total} 个任务',
29 'agent.console.task.number': '编号',
30 'agent.console.task.title': '标题',
31 'agent.console.task.state': '状态',
32 'agent.console.task.priority': '优先级',
33 'agent.console.task.createdAt': '创建时间',
34 'agent.console.task.actions': '操作',
35 'agent.console.task.start': '开始执行',
36 'agent.console.task.cancel': '取消执行',
37 'agent.console.task.close': '关闭任务',
38 'agent.console.task.reopen': '重新打开',
39 'agent.console.task.delete': '删除任务',
40 'agent.console.task.deleteConfirm': '任务及其对话、活动记录会被一起删除,且不可恢复。',
41 'agent.console.task.edit': '编辑任务',
42 'agent.console.task.watch': '关注任务',
43 'agent.console.task.back': '返回列表',
44 'agent.console.task.body': '任务描述',
45 'agent.console.task.bodyFrozen': '任务已开始执行,描述不再允许修改。',
46 'agent.console.task.noBody': '没有填写任务描述。',
47 'agent.console.task.conversation': '对话',
48 'agent.console.task.noMessages': '还没有消息。',
49 'agent.console.task.sendFailed': '发送失败',
50 'agent.console.stream.idle': '未连接',
51 'agent.console.stream.connecting': '连接中',
52 'agent.console.stream.live': '实时',
53 'agent.console.stream.reconnecting': '重连中',
54 'agent.console.stream.expired': '令牌过期',
55 'agent.console.stream.error': '连接失败',
56 'agent.console.stream.stopped': '已断开',
57 'agent.console.task.activity': '活动记录',
58 'agent.console.task.noEvents': '还没有活动记录。',
59 'agent.console.task.composerPlaceholder': '给智能体发一条消息…',
60 'agent.console.task.send': '发送',
61 'agent.console.task.startHint': '任务还没有开始;点「开始执行」把任务描述发给智能体。',
62 'agent.console.form.createTitle': '新建任务',
63 'agent.console.form.editTitle': '编辑任务',
64 'agent.console.form.title': '标题',
65 'agent.console.form.titlePlaceholder': '一句话说明这个任务要做什么',
66 'agent.console.form.titleRequired': '请填写标题',
67 'agent.console.form.description': '简述',
68 'agent.console.form.body': '任务描述',
69 'agent.console.form.priority': '优先级',
70 'agent.console.form.dueAt': '截止日期',
71 'agent.console.manage.deleteConfirm': '集合里的任务会一起删除,且不可恢复。',
72 'agent.console.manage.stats': '执行中 {running} 个 · 共 {total} 个任务',
73 'agent.console.actionFailed': '操作失败',
74 'agent.console.streamLost': '实时连接已断开',
75 'agent.console.reconnecting': '实时连接中断,正在重连…',
76 'agent.state.draft': '草稿',
77 'agent.state.pending': '排队中',
78 'agent.state.planning': '规划中',
79 'agent.state.executing': '执行中',
80 'agent.state.waiting': '等待中',
81 'agent.state.succeeded': '已完成',
82 'agent.state.failed': '已失败',
83 'agent.state.canceled': '已取消',
84 'agent.state.closed': '已关闭',
85 'agent.state.all': '全部',
86 'agent.sort.created': '最新创建',
87 'agent.sort.updated': '最近更新',
88 'agent.sort.priority': '优先级',
89 'agent.sort.number': '编号',
90 'agent.role.user': '用户',
91 'agent.role.agent': '智能体',
92 'agent.role.system': '系统',
93 'agent.role.tool': '工具',
94 'agent.watch.watching': '已关注',
95 'agent.watch.unwatched': '未关注',
96 'agent.watch.ignore': '已忽略',
97 'agent.event.created': '创建了任务',
98 'agent.event.title_updated': '修改了标题',
99 'agent.event.body_updated': '修改了任务描述',
100 'agent.event.started': '开始执行',
101 'agent.event.state_changed': '状态变化',
102 'agent.event.message_added': '收到新消息',
103 'agent.event.run_event': '执行事件',
104 'agent.event.run_failed': '执行失败',
105 'agent.event.artifact_added': '产出了新文件',
106 'agent.event.subscriber_lost': '实时订阅中断',
107 'agent.event.assigned': '指派了成员',
108 'agent.event.unassigned': '移除了成员',
109 'agent.event.label_added': '添加了标签',
110 'agent.event.label_removed': '移除了标签',
111 'agent.event.repo_relation_added': '关联了仓库',
112 'agent.event.repo_relation_removed': '取消关联仓库',
113 'agent.event.closed': '关闭了任务',
114 'agent.event.reopened': '重新打开了任务',
115 'agent.event.unknown': '未知活动',
116 'agent.event.fallback': '活动:{type}',
117 'agent.labels.title': '标签',
118 'agent.labels.empty': '还没有绑定标签。',
119 'agent.labels.add': '添加',
120 'agent.labels.new': '新建标签',
121 'agent.labels.createTitle': '新建标签',
122 'agent.labels.name': '标签名',
123 'agent.labels.color': '颜色',
124 'agent.labels.nameRequired': '请填写标签名',
125 'agent.labels.unbind': '移除标签',
126 'agent.member.title': '成员',
127 'agent.member.empty': '还没有成员。',
128 'agent.member.add': '添加',
129 'agent.member.remove': '移除成员',
130 'agent.member.assignee': '负责人',
131 'agent.member.mentioned': '被提及',
132 'agent.member.participant': '参与者',
133 'agent.artifact.title': '产物',
134 'agent.artifact.empty': '还没有产物。',
135 'agent.artifact.download': '下载',
136 'agent.artifact.missingLink': '下载链接不可用',
137 'agent.read.unread': '{count} 条未读',
138 'space.menu.createAgent': '新建智能体集',
139 'space.agent.create.title': '创建智能体集',
140 'space.agent.nameField': '名称',
141 'space.agent.description': '描述',
142 'space.agent.icon': '图标',
143 'shell.titleAgent': '{collection} · {space} · GitDataAI',
144}
No newline at end of file
Modified web/src/shared/i18n/messages/zh-CN/index.ts web/src/shared/i18n/messages/zh-CN/index.ts +0 −2 View web/src/shared/i18n/messages/zh-CN/index.ts
@@ -1,4 +1,3 @@
1import { agent } from './agent'
21import { auth } from './auth'
32import { chat } from './chat'
43import { chatComposer } from './chatComposer'
@@ -24,7 +23,6 @@
2423 * checked against it, so drift between the two fails `tsc`.
2524 */
2625export const zhCN = {
27 ...agent,
2826 ...common,
2927 ...auth,
3028 ...errors,
Modified web/src/shared/lib/cache-keys.ts web/src/shared/lib/cache-keys.ts +0 −9 View web/src/shared/lib/cache-keys.ts
@@ -83,15 +83,6 @@
8383export const issueKey = (space: string, board: string, issueId: string, part: string) =>
8484 `issue:${space}/${board}/${issueId}:${part}`
8585
86/* -------------------------------------------------------------------- agents */
87
88/** Agent collections of one space (the sidebar / picker list). */
89export const spaceAgentsKey = (space: string) => `space:${space}:agents`
90
91/** Agent-console payloads: `collection`, `tasks:{queryKey}`, `messages`, `events`. */
92export const agentKey = (space: string, collection: string, part: string) =>
93 `agent:${space}/${collection}:${part}`
94
9586/* -------------------------------------------------------------------- repos */
9687
9788/**
Deleted web/src/shared/sagent/client.ts web/src/shared/sagent/client.ts +0 −208
@@ -1,208 +0,0 @@
1import { AgentSdkError, errorFromResponse } from './errors'
2import { SseParser, toSseEvent, watermarkOf } from './sse'
3import type { MessageAccepted, MessageDto, Page, SessionDto, SseEvent, SseFrame } from './types'
4
5export interface AgentClientOptions {
6 baseUrl: string
7 token: string
8 sessionId: string
9 fetch?: typeof fetch
10}
11
12export interface StreamEventsOptions {
13 onEvent?: (event: SseEvent) => void
14 onMessage?: (message: MessageDto) => void
15 onError?: (error: AgentSdkError) => boolean | void
16 signal?: AbortSignal
17 lastEventId?: number
18 maxRetries?: number
19}
20
21const DEFAULT_MAX_RETRIES = 5
22const BASE_BACKOFF_MS = 500
23const MAX_BACKOFF_MS = 8_000
24
25export class AgentClient {
26 readonly sessionId: string
27
28 private readonly baseUrl: string
29 private readonly token: string
30 private readonly fetchImpl: typeof fetch
31
32 constructor(options: AgentClientOptions) {
33 const base = options.baseUrl.trim().replace(/\/+$/, '')
34 if (base === '') throw new AgentSdkError('baseUrl 不能为空')
35 if (options.token.trim() === '') throw new AgentSdkError('token 不能为空')
36 if (options.sessionId.trim() === '') throw new AgentSdkError('sessionId 不能为空')
37 this.baseUrl = base
38 this.token = options.token
39 this.sessionId = options.sessionId
40 const injected = options.fetch
41 if (injected !== undefined) {
42 this.fetchImpl = injected
43 } else if (typeof globalThis.fetch === 'function') {
44 this.fetchImpl = globalThis.fetch.bind(globalThis)
45 } else {
46 throw new AgentSdkError('当前环境没有 fetch;请在 Node 18+ 或浏览器中使用')
47 }
48 }
49
50 private url(path: string): string {
51 return `${this.baseUrl}${path}`
52 }
53
54 private headers(extra?: Record<string, string>): Record<string, string> {
55 return { authorization: `Bearer ${this.token}`, ...extra }
56 }
57
58 private async requestJson<T>(path: string, init?: RequestInit): Promise<T> {
59 let response: Response
60 try {
61 response = await this.fetchImpl(this.url(path), {
62 ...init,
63 headers: this.headers({
64 'content-type': 'application/json',
65 ...(init?.headers as Record<string, string> | undefined),
66 }),
67 })
68 } catch (cause) {
69 throw new AgentSdkError(`请求失败: ${String(cause)}`, { cause })
70 }
71 if (!response.ok) throw await errorFromResponse(response)
72 const text = await response.text()
73 try {
74 return JSON.parse(text) as T
75 } catch (cause) {
76 throw new AgentSdkError(`响应不是合法 JSON: ${text.slice(0, 200)}`, { cause })
77 }
78 }
79
80 async getSession(): Promise<SessionDto> {
81 return this.requestJson<SessionDto>(`/api/v1/sessions/${this.sessionId}`)
82 }
83
84 async listMessages(options: { limit?: number; cursor?: number } = {}): Promise<Page<MessageDto>> {
85 const query = new URLSearchParams()
86 if (options.limit !== undefined) query.set('limit', String(options.limit))
87 if (options.cursor !== undefined) query.set('cursor', String(options.cursor))
88 const suffix = query.toString() === '' ? '' : `?${query.toString()}`
89 return this.requestJson<Page<MessageDto>>(`/api/v1/sessions/${this.sessionId}/messages${suffix}`)
90 }
91
92 async sendMessage(text: string, idempotencyKey?: string): Promise<MessageAccepted> {
93 if (text.trim() === '') throw new AgentSdkError('text 不能为空')
94 const body: Record<string, unknown> = { text }
95 if (idempotencyKey !== undefined) body.idempotency_key = idempotencyKey
96 return this.requestJson<MessageAccepted>(`/api/v1/sessions/${this.sessionId}/messages`, {
97 method: 'POST',
98 body: JSON.stringify(body),
99 })
100 }
101
102 async streamEvents(options: StreamEventsOptions = {}): Promise<void> {
103 const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
104 let watermark = options.lastEventId ?? 0
105 let attempt = 0
106
107 while (!options.signal?.aborted) {
108 try {
109 const stopped = await this.streamOnce(
110 options,
111 () => watermark,
112 seq => {
113 watermark = seq
114 },
115 )
116 if (stopped) return
117 } catch (cause) {
118 const error =
119 cause instanceof AgentSdkError
120 ? cause
121 : new AgentSdkError(`SSE 中断: ${String(cause)}`, { cause })
122 // A `false` verdict means "do not retry this stream" — the caller either
123 // refreshed the token (and will reopen it) or gave up.
124 if (options.onError?.(error) === false) return
125 }
126 if (attempt >= maxRetries) return
127 attempt += 1
128 const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (attempt - 1), MAX_BACKOFF_MS)
129 await sleep(backoff, options.signal)
130 }
131 }
132
133 private async streamOnce(
134 options: StreamEventsOptions,
135 watermark: () => number,
136 update: (seq: number) => void,
137 ): Promise<boolean> {
138 const headers = this.headers({ accept: 'text/event-stream' })
139 // The gateway resumes strictly after the watermark, so a reconnect during a
140 // long run replays only what this client has not seen.
141 if (watermark() > 0) headers['last-event-id'] = `m${watermark()}`
142
143 let response: Response
144 try {
145 response = await this.fetchImpl(this.url(`/api/v1/sessions/${this.sessionId}/events`), {
146 headers,
147 signal: options.signal,
148 })
149 } catch (cause) {
150 if (options.signal?.aborted) return true
151 throw new AgentSdkError(`SSE 连接失败: ${String(cause)}`, { cause })
152 }
153 if (!response.ok) throw await errorFromResponse(response)
154 if (response.body === null) throw new AgentSdkError('SSE 响应没有 body')
155
156 const reader = response.body.getReader()
157 const decoder = new TextDecoder()
158 const parser = new SseParser()
159 try {
160 while (true) {
161 const { done, value } = await reader.read()
162 if (done) break
163 parser.push(decoder.decode(value, { stream: true }))
164 for (let frame = parser.popFrame(); frame !== null; frame = parser.popFrame()) {
165 this.dispatch(frame, options, update)
166 }
167 }
168 const tail = parser.flush()
169 if (tail !== null) this.dispatch(tail, options, update)
170 return false
171 } catch (cause) {
172 if (options.signal?.aborted) return true
173 throw new AgentSdkError(`SSE 读取失败: ${String(cause)}`, { cause })
174 } finally {
175 try {
176 await reader.cancel()
177 } catch {
178 // The body is already unusable; nothing to release.
179 }
180 }
181 }
182
183 private dispatch(
184 frame: SseFrame,
185 options: StreamEventsOptions,
186 update: (seq: number) => void,
187 ): void {
188 const seq = watermarkOf(frame)
189 if (seq !== null && seq > 0) update(seq)
190 const event = toSseEvent(frame)
191 options.onEvent?.(event)
192 if (event.type === 'message_added') options.onMessage?.(event.message)
193 }
194}
195
196function sleep(ms: number, signal?: AbortSignal): Promise<void> {
197 return new Promise(resolve => {
198 const timer = setTimeout(resolve, ms)
199 signal?.addEventListener(
200 'abort',
201 () => {
202 clearTimeout(timer)
203 resolve()
204 },
205 { once: true },
206 )
207 })
208}
No newline at end of file
Deleted web/src/shared/sagent/errors.ts web/src/shared/sagent/errors.ts +0 −61
@@ -1,61 +0,0 @@
1import type { ApiErrorBody } from './types'
2
3export class AgentSdkError extends Error {
4 readonly status: number | undefined
5 readonly code: string | undefined
6 readonly retryAfterMs: number | undefined
7 readonly body: string | undefined
8
9 constructor(
10 message: string,
11 options: {
12 status?: number
13 code?: string
14 retryAfterMs?: number
15 body?: string
16 cause?: unknown
17 } = {},
18 ) {
19 super(message, options.cause === undefined ? undefined : { cause: options.cause })
20 this.name = 'AgentSdkError'
21 this.status = options.status
22 this.code = options.code
23 this.retryAfterMs = options.retryAfterMs
24 this.body = options.body
25 }
26
27 get isUnauthorized(): boolean {
28 return this.status === 401
29 }
30
31 get isForbidden(): boolean {
32 return this.status === 403
33 }
34
35 get isNotFound(): boolean {
36 return this.status === 404
37 }
38
39 get isRetryable(): boolean {
40 if (this.status === undefined) return true
41 if (this.status === 429) return true
42 return this.status >= 500
43 }
44}
45
46export async function errorFromResponse(response: Response): Promise<AgentSdkError> {
47 const body = await response.text().catch(() => '')
48 let parsed: ApiErrorBody | undefined
49 try {
50 parsed = JSON.parse(body) as ApiErrorBody
51 } catch {
52 parsed = undefined
53 }
54 const message = parsed?.message ?? body.trim() ?? response.statusText
55 return new AgentSdkError(message, {
56 status: response.status,
57 code: parsed?.code,
58 retryAfterMs: parsed?.retry_after_ms,
59 body,
60 })
61}
No newline at end of file
Deleted web/src/shared/sagent/index.ts web/src/shared/sagent/index.ts +0 −43
@@ -1,43 +0,0 @@
1// Public API of the shared/sagent segment — the browser-direct connection to
2// the sandbox agent gateway at `agent.gitdata.ai`.
3//
4// The gateway is a different origin from this app and the backend deliberately
5// does not proxy it: `apihost` mints a short-lived session JWT
6// (`GET /api/v1/agents/{space}/collections/{c}/tasks/{t}/token`) and the browser
7// talks to `/api/v1/sessions/{session}/**` itself. That is why the client lives
8// in `shared` — nothing about it is specific to the 智能体 console.
9//
10// Typical usage:
11// const stream = useAgentStream({
12// sessionId: task.session_id,
13// fetchToken: async () => {
14// const { data } = await Apis.sagent.get_task_token({ pathParams: {...} })
15// return { sessionId: data.session_id, token: data.token, baseUrl: data.base_url, expiresAt: data.expires_at }
16// },
17// })
18// stream.messages // live frames, seq-ascending
19// stream.status // idle | connecting | live | reconnecting | expired | error | stopped
20// await stream.send('跑一下测试') // POST straight to the gateway
21export { AgentClient, type AgentClientOptions, type StreamEventsOptions } from './client'
22export { AgentSdkError, errorFromResponse } from './errors'
23export { SseParser, toSseEvent, watermarkOf } from './sse'
24export {
25 mergeAgentMessages,
26 useAgentStream,
27 type AgentStreamState,
28 type AgentStreamStatus,
29 type AgentStreamToken,
30 type UseAgentStreamOptions,
31 type UseAgentStreamResult,
32} from './react'
33export type {
34 ApiErrorBody,
35 MessageAccepted,
36 MessageDto,
37 MessageRole,
38 Page,
39 SessionDto,
40 SseEvent,
41 SseEventName,
42 SseFrame,
43} from './types'
No newline at end of file
Deleted web/src/shared/sagent/react.ts web/src/shared/sagent/react.ts +0 −243
@@ -1,243 +0,0 @@
1/**
2 * React binding for the agent gateway's session stream. Browser-direct by
3 * design: the backend only mints the session JWT (`GET …/tasks/{id}/token`),
4 * and this hook opens `GET /api/v1/sessions/{session}/events` itself.
5 *
6 * Three things the raw `AgentClient` does not do:
7 *
8 * - **Token lifetime.** The JWT lives 600s, which is shorter than a long run.
9 * The hook silently re-mints shortly before `expiresAt` and reopens the
10 * stream at the same watermark, so a subscriber never sees the gap.
11 * - **Resume across reconnects.** `Last-Event-ID` is derived from the frames
12 * the caller has already been told about, and it survives `fetchToken`
13 * rotation.
14 * - **State for React.** Messages are deduplicated by `seq` and kept ordered,
15 * because a resumed stream legitimately replays frames.
16 */
17import { useCallback, useEffect, useRef, useState } from 'react'
18import { AgentClient } from './client'
19import { AgentSdkError } from './errors'
20import type { MessageDto, SseEvent } from './types'
21
22/** What the token endpoint hands the browser. */
23export interface AgentStreamToken {
24 sessionId: string
25 token: string
26 baseUrl: string
27 /** ISO-8601; the hook refreshes 60s before this. */
28 expiresAt: string
29}
30
31export type AgentStreamStatus =
32 | 'idle'
33 | 'connecting'
34 | 'live'
35 | 'reconnecting'
36 | 'expired'
37 | 'error'
38 | 'stopped'
39
40export interface UseAgentStreamOptions {
41 /** Gateway session behind the task; `null` keeps the hook idle. */
42 sessionId?: string | null
43 /** Callers can hold the stream closed (e.g. the panel is collapsed). */
44 enabled?: boolean
45 fetchToken: () => Promise<AgentStreamToken>
46 onMessage?: (message: MessageDto) => void
47 onEvent?: (event: SseEvent) => void
48 onRunState?: (state: string) => void
49}
50
51export interface AgentStreamState {
52 status: AgentStreamStatus
53 /** Live frames only, `seq`-ascending. Merge with the archive for display. */
54 messages: MessageDto[]
55 runState?: string
56 error?: string
57 /** Highest `message_added` seq seen — the resume cursor. */
58 watermark: number
59}
60
61export interface UseAgentStreamResult extends AgentStreamState {
62 /** POST the text straight to the gateway (the API proxy has no send route). */
63 send: (text: string) => Promise<void>
64 /** Drop the token and reopen the stream at the current watermark. */
65 reconnect: () => void
66}
67
68const REFRESH_LEAD_MS = 60_000
69
70/**
71 * Keep one session stream open for as long as the caller is mounted. The
72 * returned `messages` are the frames this client actually saw; the caller owns
73 * the historical ones.
74 */
75export function useAgentStream(options: UseAgentStreamOptions): UseAgentStreamResult {
76 const { sessionId, enabled = true, fetchToken } = options
77
78 const [state, setState] = useState<AgentStreamState & { sessionId: string | null }>({
79 status: 'idle',
80 messages: [],
81 watermark: 0,
82 sessionId: null,
83 })
84 const [generation, setGeneration] = useState(0)
85
86 const handlers = useRef(options)
87 const fetchTokenRef = useRef(fetchToken)
88 const tokenRef = useRef<AgentStreamToken | null>(null)
89 const clientRef = useRef<AgentClient | null>(null)
90 const watermarkRef = useRef(0)
91 const watermarkSessionRef = useRef<string | null>(null)
92 const sessionRef = useRef<string | null>(sessionId ?? null)
93
94 // The latest callbacks and inputs live in refs so the stream is opened once per
95 // generation: a re-render (a keystroke in the composer) must not reconnect.
96 useEffect(() => {
97 handlers.current = options
98 fetchTokenRef.current = fetchToken
99 sessionRef.current = sessionId ?? null
100 })
101
102 const ensureClient = useCallback(async (): Promise<AgentClient> => {
103 const session = sessionRef.current
104 if (!session) throw new AgentSdkError('会话尚未创建,暂时无法连接智能体网关')
105 const current = clientRef.current
106 if (current !== null && current.sessionId === session) return current
107 // Minting a JWT is a round trip to the API host, so the panel says
108 // "connecting" from here rather than from the effect that opened the stream.
109 setState(prev => ({ ...prev, status: 'connecting', error: undefined }))
110 const minted = tokenRef.current ?? (await fetchTokenRef.current())
111 tokenRef.current = minted
112 const client = new AgentClient({
113 baseUrl: minted.baseUrl,
114 token: minted.token,
115 sessionId: minted.sessionId || session,
116 })
117 clientRef.current = client
118 return client
119 }, [])
120
121 const reconnect = useCallback(() => {
122 tokenRef.current = null
123 clientRef.current = null
124 setGeneration(n => n + 1)
125 }, [])
126
127 useEffect(() => {
128 if (!enabled || !sessionId) return
129 const controller = new AbortController()
130 let timer: ReturnType<typeof setTimeout> | undefined
131
132 void (async () => {
133 try {
134 const client = await ensureClient()
135 if (controller.signal.aborted) return
136 // The resume cursor belongs to one gateway session: reusing it across
137 // tasks would ask the new session for frames it never had.
138 if (watermarkSessionRef.current !== sessionId) {
139 watermarkRef.current = 0
140 watermarkSessionRef.current = sessionId
141 }
142 setState(prev => ({
143 ...prev,
144 status: 'live',
145 error: undefined,
146 sessionId,
147 watermark: watermarkRef.current,
148 messages: prev.sessionId === sessionId ? prev.messages : [],
149 }))
150
151 const minted = tokenRef.current
152 if (minted !== null) {
153 // Silent rotation: the SDK's own retry loop would replay a dead JWT.
154 const until = Date.parse(minted.expiresAt) - Date.now() - REFRESH_LEAD_MS
155 timer = setTimeout(
156 () => {
157 tokenRef.current = null
158 setGeneration(n => n + 1)
159 },
160 Math.max(1_000, Number.isFinite(until) ? until : 0),
161 )
162 }
163
164 await client.streamEvents({
165 signal: controller.signal,
166 lastEventId: watermarkRef.current,
167 onMessage: message => {
168 watermarkRef.current = Math.max(watermarkRef.current, message.seq)
169 setState(prev => ({
170 ...prev,
171 watermark: watermarkRef.current,
172 messages: prev.messages.some(existing => existing.seq === message.seq)
173 ? prev.messages
174 : [...prev.messages, message].sort((a, b) => a.seq - b.seq),
175 }))
176 handlers.current.onMessage?.(message)
177 },
178 onEvent: event => handlers.current.onEvent?.(event),
179 onError: error => {
180 if (error.isUnauthorized || error.isForbidden) {
181 tokenRef.current = null
182 clientRef.current = null
183 setState(prev => ({ ...prev, status: 'expired', error: error.message }))
184 setGeneration(n => n + 1)
185 return false
186 }
187 setState(prev => ({ ...prev, status: 'reconnecting', error: error.message }))
188 return undefined
189 },
190 })
191 if (!controller.signal.aborted) setState(prev => ({ ...prev, status: 'stopped' }))
192 } catch (cause) {
193 if (controller.signal.aborted) return
194 setState(prev => ({
195 ...prev,
196 status: 'error',
197 error: cause instanceof Error ? cause.message : String(cause),
198 }))
199 }
200 })()
201
202 return () => {
203 controller.abort()
204 if (timer !== undefined) clearTimeout(timer)
205 clientRef.current = null
206 }
207 }, [enabled, sessionId, generation, ensureClient])
208
209 const send = useCallback(
210 async (text: string) => {
211 const client = await ensureClient()
212 await client.sendMessage(text)
213 },
214 [ensureClient],
215 )
216
217 // A stream that is not being held open for this session reports `idle` and no
218 // frames, whatever the previous session left behind.
219 const attached = enabled && Boolean(sessionId) && state.sessionId === sessionId
220 return {
221 ...state,
222 status: attached ? state.status : 'idle',
223 messages: attached ? state.messages : [],
224 watermark: attached ? state.watermark : 0,
225 send,
226 reconnect,
227 }
228}
229
230/**
231 * Merge the archive (rows the backend's subscriber persisted) with the live
232 * frames, newest per `seq`, oldest first.
233 */
234export function mergeAgentMessages<T extends { seq: number }>(
235 archive: readonly T[],
236 live: readonly T[],
237): T[] {
238 if (live.length === 0) return [...archive]
239 const bySeq = new Map<number, T>()
240 for (const message of archive) bySeq.set(message.seq, message)
241 for (const message of live) bySeq.set(message.seq, message)
242 return [...bySeq.values()].sort((a, b) => a.seq - b.seq)
243}
No newline at end of file
Deleted web/src/shared/sagent/sse.ts web/src/shared/sagent/sse.ts +0 −79
@@ -1,79 +0,0 @@
1import type { MessageDto, SseEvent, SseFrame } from './types'
2
3/**
4 * 增量 SSE 解析器:跨 chunk 保持状态。
5 *
6 * 浏览器的 `EventSource` 无法带 `Authorization` 头,因此 SDK 用 `fetch` +
7 * `ReadableStream` 手工解析。这里按空行切帧,`data:` 可跨多行。
8 */
9export class SseParser {
10 private buffer = ''
11
12 push(chunk: string): void {
13 this.buffer += chunk
14 this.buffer = this.buffer.replace(/\r\n/g, '\n')
15 }
16
17 popFrame(): SseFrame | null {
18 const sep = this.buffer.indexOf('\n\n')
19 if (sep < 0) return null
20 const block = this.buffer.slice(0, sep)
21 this.buffer = this.buffer.slice(sep + 2)
22 return parseBlock(block)
23 }
24
25 flush(): SseFrame | null {
26 if (this.buffer.trim() === '') {
27 this.buffer = ''
28 return null
29 }
30 const block = this.buffer
31 this.buffer = ''
32 return parseBlock(block)
33 }
34}
35
36function parseBlock(block: string): SseFrame | null {
37 let event: string | undefined
38 let id: string | undefined
39 const dataLines: string[] = []
40 for (const line of block.split('\n')) {
41 if (line.startsWith('event:')) event = line.slice(6).trim()
42 else if (line.startsWith('id:')) id = line.slice(3).trim()
43 else if (line.startsWith('data:')) {
44 const value = line.slice(5)
45 dataLines.push(value.startsWith(' ') ? value.slice(1) : value)
46 }
47 }
48 if (event === undefined) return null
49 const frame: SseFrame = { event, data: dataLines.join('\n') }
50 if (id !== undefined && id !== '') frame.id = id
51 return frame
52}
53
54export function toSseEvent(frame: SseFrame): SseEvent {
55 try {
56 if (frame.event === 'message_added') {
57 return { type: 'message_added', message: JSON.parse(frame.data) as MessageDto }
58 }
59 if (frame.event === 'run_event') {
60 const p = JSON.parse(frame.data) as { run_id: string; seq: number; event: unknown }
61 return { type: 'run_event', run_id: p.run_id, seq: p.seq, event: p.event }
62 }
63 if (frame.event === 'run_state') {
64 const p = JSON.parse(frame.data) as { run_id: string; state: string }
65 return { type: 'run_state', run_id: p.run_id, state: p.state }
66 }
67 } catch {
68 // 未识别/半截的帧按 `other` 透传,交给上层决定是否忽略。
69 }
70 return { type: 'other', event: frame.event, data: frame.data }
71}
72
73export function watermarkOf(frame: SseFrame): number | null {
74 if (frame.event !== 'message_added') return null
75 const raw = frame.id
76 if (raw === undefined || !raw.startsWith('m')) return null
77 const seq = Number.parseInt(raw.slice(1), 10)
78 return Number.isFinite(seq) ? seq : null
79}
No newline at end of file
Deleted web/src/shared/sagent/types.ts web/src/shared/sagent/types.ts +0 −67
@@ -1,67 +0,0 @@
1/**
2 * Vendored from `agent/sdk/ts-sdk` (`@gitdataai/agent-sdk` 0.1.0, `private: true`,
3 * never published) — the browser-side client for the sandbox agent gateway at
4 * `agent.gitdata.ai`.
5 *
6 * It lives in `shared` because the connection belongs to a *session* on a
7 * different origin than this app, and only `useAgentStream` knows how to hold
8 * one open; nothing about it is specific to the 智能体 console.
9 *
10 * Kept byte-identical to upstream except for extensionless relative imports
11 * (see `client.ts`/`sse.ts`/`errors.ts`), so a `diff -r` against
12 * `C:/code/GitDataAi/agent/sdk/ts-sdk/src` stays readable.
13 */
14export type MessageRole = 'user' | 'assistant'
15
16export interface SessionDto {
17 session_id: string
18 title: string
19 metadata: Record<string, unknown>
20 created_at_millis: number
21 updated_at_millis: number
22 deleted_at_millis?: number
23}
24
25export interface MessageDto {
26 message_id: string
27 session_id: string
28 seq: number
29 role: MessageRole
30 content: string
31 run_id?: string
32 created_at_millis: number
33}
34
35export interface Page<T> {
36 items: T[]
37 next_cursor?: string
38}
39
40export interface MessageAccepted {
41 message_id: string
42 session_id: string
43 seq: number
44 run_id: string
45 state: string
46 events_url: string
47}
48
49export interface ApiErrorBody {
50 code: string
51 message: string
52 retry_after_ms?: number
53}
54
55export type SseEventName = 'message_added' | 'run_event' | 'run_state' | 'run_finished' | string
56
57export interface SseFrame {
58 event: SseEventName
59 id?: string
60 data: string
61}
62
63export type SseEvent =
64 | { type: 'message_added'; message: MessageDto }
65 | { type: 'run_event'; run_id: string; seq: number; event: unknown }
66 | { type: 'run_state'; run_id: string; state: string }
67 | { type: 'other'; event: string; data: string }
No newline at end of file
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified web/steiger.config.js web/steiger.config.js +0 −1 View web/steiger.config.js
@@ -12,7 +12,6 @@
1212// 本清单应随架构演进同步修剪(切片被第二个消费者使用,或删除后记得移除)。
1313const INSIGNIFICANT_SLICE_ALLOWLIST = new Set([
1414 'entities/search',
15 'features/agent-console',
1615 'features/chat',
1716 'features/issue-board',
1817 'features/notifications',
Modified Cargo.lock Cargo.lock +0 −84 View Cargo.lock
@@ -168,37 +168,6 @@
168168]
169169
170170[[package]]
171name = "agent-sdk"
172version = "1.0.0"
173dependencies = [
174 "agent-types",
175 "bytes",
176 "futures",
177 "reqwest 0.12.28",
178 "serde",
179 "serde_json",
180 "smol_str",
181 "thiserror 2.0.20",
182 "tokio",
183]
184
185[[package]]
186name = "agent-types"
187version = "1.0.0"
188dependencies = [
189 "async-trait",
190 "chrono",
191 "hex",
192 "rand 0.8.7",
193 "serde",
194 "serde_json",
195 "sha2 0.11.0",
196 "smol_str",
197 "thiserror 2.0.20",
198 "uuid",
199]
200
201[[package]]
202171name = "ahash"
203172version = "0.7.8"
204173source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -310,7 +279,6 @@
310279 "config",
311280 "export",
312281 "push",
313 "sagent",
314282 "session",
315283 "tokio",
316284 "tokio-util",
@@ -8017,7 +7985,6 @@
80177985 "base64 0.22.1",
80187986 "bytes",
80197987 "futures-core",
8020 "futures-util",
80217988 "http 1.5.0",
80227989 "http-body 1.1.0",
80237990 "http-body-util",
@@ -8037,14 +8004,12 @@
80378004 "sync_wrapper",
80388005 "tokio",
80398006 "tokio-rustls",
8040 "tokio-util",
80418007 "tower",
80428008 "tower-http 0.6.11",
80438009 "tower-service",
80448010 "url 2.5.8",
80458011 "wasm-bindgen",
80468012 "wasm-bindgen-futures",
8047 "wasm-streams",
80488013 "web-sys",
80498014 "webpki-roots 1.0.9",
80508015]
@@ -8546,31 +8511,6 @@
85468511checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072"
85478512
85488513[[package]]
8549name = "sagent"
8550version = "1.0.0"
8551dependencies = [
8552 "agent-sdk",
8553 "agent-types",
8554 "async-trait",
8555 "axum",
8556 "config",
8557 "export",
8558 "futures",
8559 "models",
8560 "sea-orm",
8561 "serde",
8562 "serde_json",
8563 "session",
8564 "smol_str",
8565 "space",
8566 "time 0.3.55",
8567 "tokio",
8568 "tracing",
8569 "utoipa",
8570 "uuid",
8571]
8572
8573[[package]]
85748514name = "salsa20"
85758515version = "0.11.0"
85768516source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9205,16 +9145,6 @@
92059145]
92069146
92079147[[package]]
9208name = "smol_str"
9209version = "0.3.2"
9210source = "registry+https://github.com/rust-lang/crates.io-index"
9211checksum = "9676b89cd56310a87b93dec47b11af744f34d5fc9f367b829474eec0a891350d"
9212dependencies = [
9213 "borsh",
9214 "serde",
9215]
9216
9217[[package]]
92189148name = "social"
92199149version = "1.0.0"
92209150dependencies = [
@@ -10821,19 +10751,6 @@
1082110751]
1082210752
1082310753[[package]]
10824name = "wasm-streams"
10825version = "0.4.2"
10826source = "registry+https://github.com/rust-lang/crates.io-index"
10827checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
10828dependencies = [
10829 "futures-util",
10830 "js-sys",
10831 "wasm-bindgen",
10832 "wasm-bindgen-futures",
10833 "web-sys",
10834]
10835
10836[[package]]
1083710754name = "web"
1083810755version = "0.1.0"
1083910756
@@ -10893,7 +10810,6 @@
1089310810 "opentelemetry-otlp",
1089410811 "opentelemetry_sdk",
1089510812 "pulls",
10896 "sagent",
1089710813 "search",
1089810814 "serde",
1089910815 "session",
Modified Cargo.toml Cargo.toml +0 −3 View Cargo.toml
@@ -17,8 +17,6 @@
1717[workspace.dependencies]
1818admin = { path = "lib/admin" }
1919agent = { path = "lib/agent" }
20agent-sdk = { path = "lib/agent-sdk" }
21agent-types = { path = "lib/agent-types" }
2220aihost = { path = "app/aihost" }
2321admin-web = { path = "admin" }
2422auth = { path = "lib/auth" }
@@ -43,7 +41,6 @@
4341import = { path = "lib/import" }
4442webapi = { path = "lib/webapi" }
4543pulls = { path = "lib/pulls" }
46sagent = { path = "lib/sagent" }
4744search = { path = "lib/search" }
4845social = { path = "lib/social" }
4946web = { path = "web" }
Modified openapi.json openapi.json View openapi.json
Diff too large to display.