1use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "backend")]
12pub mod crypto;
13#[cfg(feature = "backend")]
14pub mod db;
15pub mod deploy;
16pub mod oauth;
17#[cfg(feature = "backend")]
18pub mod service;
19
20pub use opensession_core::trace::{
22 Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31#[cfg_attr(feature = "ts", ts(export))]
32pub enum SortOrder {
33 #[default]
34 Recent,
35 Popular,
36 Longest,
37}
38
39impl SortOrder {
40 pub fn as_str(&self) -> &str {
41 match self {
42 Self::Recent => "recent",
43 Self::Popular => "popular",
44 Self::Longest => "longest",
45 }
46 }
47}
48
49impl std::fmt::Display for SortOrder {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 f.write_str(self.as_str())
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
57#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "ts", ts(export))]
59pub enum TimeRange {
60 #[serde(rename = "24h")]
61 Hours24,
62 #[serde(rename = "7d")]
63 Days7,
64 #[serde(rename = "30d")]
65 Days30,
66 #[default]
67 #[serde(rename = "all")]
68 All,
69}
70
71impl TimeRange {
72 pub fn as_str(&self) -> &str {
73 match self {
74 Self::Hours24 => "24h",
75 Self::Days7 => "7d",
76 Self::Days30 => "30d",
77 Self::All => "all",
78 }
79 }
80}
81
82impl std::fmt::Display for TimeRange {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.write_str(self.as_str())
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case")]
91#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
92#[cfg_attr(feature = "ts", ts(export))]
93pub enum LinkType {
94 Handoff,
95 Related,
96 Parent,
97 Child,
98}
99
100impl LinkType {
101 pub fn as_str(&self) -> &str {
102 match self {
103 Self::Handoff => "handoff",
104 Self::Related => "related",
105 Self::Parent => "parent",
106 Self::Child => "child",
107 }
108 }
109}
110
111impl std::fmt::Display for LinkType {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.write_str(self.as_str())
114 }
115}
116
117pub fn saturating_i64(v: u64) -> i64 {
121 i64::try_from(v).unwrap_or(i64::MAX)
122}
123
124#[derive(Debug, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct RegisterRequest {
131 pub nickname: String,
132}
133
134#[derive(Debug, Serialize, Deserialize)]
136#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
137#[cfg_attr(feature = "ts", ts(export))]
138pub struct AuthRegisterRequest {
139 pub email: String,
140 pub password: String,
141 pub nickname: String,
142}
143
144#[derive(Debug, Serialize, Deserialize)]
146#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
147#[cfg_attr(feature = "ts", ts(export))]
148pub struct LoginRequest {
149 pub email: String,
150 pub password: String,
151}
152
153#[derive(Debug, Serialize, Deserialize)]
155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
156#[cfg_attr(feature = "ts", ts(export))]
157pub struct AuthTokenResponse {
158 pub access_token: String,
159 pub refresh_token: String,
160 pub expires_in: u64,
161 pub user_id: String,
162 pub nickname: String,
163}
164
165#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[cfg_attr(feature = "ts", ts(export))]
169pub struct RefreshRequest {
170 pub refresh_token: String,
171}
172
173#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
176#[cfg_attr(feature = "ts", ts(export))]
177pub struct LogoutRequest {
178 pub refresh_token: String,
179}
180
181#[derive(Debug, Serialize, Deserialize)]
183#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
184#[cfg_attr(feature = "ts", ts(export))]
185pub struct ChangePasswordRequest {
186 pub current_password: String,
187 pub new_password: String,
188}
189
190#[derive(Debug, Serialize)]
192#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
193#[cfg_attr(feature = "ts", ts(export))]
194pub struct RegisterResponse {
195 pub user_id: String,
196 pub nickname: String,
197 pub api_key: String,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
202#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
203#[cfg_attr(feature = "ts", ts(export))]
204pub struct VerifyResponse {
205 pub user_id: String,
206 pub nickname: String,
207}
208
209#[derive(Debug, Serialize, Deserialize)]
211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
212#[cfg_attr(feature = "ts", ts(export))]
213pub struct UserSettingsResponse {
214 pub user_id: String,
215 pub nickname: String,
216 pub api_key: String,
217 pub created_at: String,
218 pub email: Option<String>,
219 pub avatar_url: Option<String>,
220 #[serde(default)]
222 pub oauth_providers: Vec<oauth::LinkedProvider>,
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub github_username: Option<String>,
226}
227
228#[derive(Debug, Serialize, Deserialize)]
230#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
231#[cfg_attr(feature = "ts", ts(export))]
232pub struct OkResponse {
233 pub ok: bool,
234}
235
236#[derive(Debug, Serialize, Deserialize)]
238#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
239#[cfg_attr(feature = "ts", ts(export))]
240pub struct RegenerateKeyResponse {
241 pub api_key: String,
242}
243
244#[derive(Debug, Serialize)]
246#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
247#[cfg_attr(feature = "ts", ts(export))]
248pub struct OAuthLinkResponse {
249 pub url: String,
250}
251
252#[derive(Debug, Serialize, Deserialize)]
256pub struct UploadRequest {
257 pub session: Session,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub body_url: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub linked_session_ids: Option<Vec<String>>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub git_remote: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub git_branch: Option<String>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub git_commit: Option<String>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub git_repo_name: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub pr_number: Option<i64>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub pr_url: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub score_plugin: Option<String>,
276}
277
278#[derive(Debug, Serialize, Deserialize)]
280#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
281#[cfg_attr(feature = "ts", ts(export))]
282pub struct UploadResponse {
283 pub id: String,
284 pub url: String,
285 #[serde(default)]
286 pub session_score: i64,
287 #[serde(default = "default_score_plugin")]
288 pub score_plugin: String,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
294#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
295#[cfg_attr(feature = "ts", ts(export))]
296pub struct SessionSummary {
297 pub id: String,
298 pub user_id: Option<String>,
299 pub nickname: Option<String>,
300 pub tool: String,
301 pub agent_provider: Option<String>,
302 pub agent_model: Option<String>,
303 pub title: Option<String>,
304 pub description: Option<String>,
305 pub tags: Option<String>,
307 pub created_at: String,
308 pub uploaded_at: String,
309 pub message_count: i64,
310 pub task_count: i64,
311 pub event_count: i64,
312 pub duration_seconds: i64,
313 pub total_input_tokens: i64,
314 pub total_output_tokens: i64,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub git_remote: Option<String>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub git_branch: Option<String>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub git_commit: Option<String>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub git_repo_name: Option<String>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub pr_number: Option<i64>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub pr_url: Option<String>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub working_directory: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub files_modified: Option<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub files_read: Option<String>,
333 #[serde(default)]
334 pub has_errors: bool,
335 #[serde(default = "default_max_active_agents")]
336 pub max_active_agents: i64,
337 #[serde(default)]
338 pub session_score: i64,
339 #[serde(default = "default_score_plugin")]
340 pub score_plugin: String,
341}
342
343#[derive(Debug, Serialize, Deserialize)]
345#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
346#[cfg_attr(feature = "ts", ts(export))]
347pub struct SessionListResponse {
348 pub sessions: Vec<SessionSummary>,
349 pub total: i64,
350 pub page: u32,
351 pub per_page: u32,
352}
353
354#[derive(Debug, Deserialize)]
356#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
357#[cfg_attr(feature = "ts", ts(export))]
358pub struct SessionListQuery {
359 #[serde(default = "default_page")]
360 pub page: u32,
361 #[serde(default = "default_per_page")]
362 pub per_page: u32,
363 pub search: Option<String>,
364 pub tool: Option<String>,
365 pub sort: Option<SortOrder>,
367 pub time_range: Option<TimeRange>,
369}
370
371impl SessionListQuery {
372 pub fn is_public_feed_cacheable(
374 &self,
375 has_auth_header: bool,
376 has_session_cookie: bool,
377 ) -> bool {
378 !has_auth_header
379 && !has_session_cookie
380 && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
381 && self.page <= 10
382 && self.per_page <= 50
383 }
384}
385
386#[cfg(test)]
387mod session_list_query_tests {
388 use super::*;
389
390 fn base_query() -> SessionListQuery {
391 SessionListQuery {
392 page: 1,
393 per_page: 20,
394 search: None,
395 tool: None,
396 sort: None,
397 time_range: None,
398 }
399 }
400
401 #[test]
402 fn public_feed_cacheable_when_anonymous_default_feed() {
403 let q = base_query();
404 assert!(q.is_public_feed_cacheable(false, false));
405 }
406
407 #[test]
408 fn public_feed_not_cacheable_with_auth_or_cookie() {
409 let q = base_query();
410 assert!(!q.is_public_feed_cacheable(true, false));
411 assert!(!q.is_public_feed_cacheable(false, true));
412 }
413
414 #[test]
415 fn public_feed_not_cacheable_for_search_or_large_page() {
416 let mut q = base_query();
417 q.search = Some("hello".into());
418 assert!(!q.is_public_feed_cacheable(false, false));
419
420 let mut q = base_query();
421 q.page = 11;
422 assert!(!q.is_public_feed_cacheable(false, false));
423
424 let mut q = base_query();
425 q.per_page = 100;
426 assert!(!q.is_public_feed_cacheable(false, false));
427 }
428}
429
430fn default_page() -> u32 {
431 1
432}
433fn default_per_page() -> u32 {
434 20
435}
436fn default_max_active_agents() -> i64 {
437 1
438}
439
440fn default_score_plugin() -> String {
441 opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
442}
443
444#[derive(Debug, Serialize, Deserialize)]
446#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
447#[cfg_attr(feature = "ts", ts(export))]
448pub struct SessionDetail {
449 #[serde(flatten)]
450 #[cfg_attr(feature = "ts", ts(flatten))]
451 pub summary: SessionSummary,
452 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub linked_sessions: Vec<SessionLink>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize)]
458#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
459#[cfg_attr(feature = "ts", ts(export))]
460pub struct SessionLink {
461 pub session_id: String,
462 pub linked_session_id: String,
463 pub link_type: LinkType,
464 pub created_at: String,
465}
466
467#[derive(Debug, Deserialize)]
471#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
472#[cfg_attr(feature = "ts", ts(export))]
473pub struct StreamEventsRequest {
474 #[cfg_attr(feature = "ts", ts(type = "any"))]
475 pub agent: Option<Agent>,
476 #[cfg_attr(feature = "ts", ts(type = "any"))]
477 pub context: Option<SessionContext>,
478 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
479 pub events: Vec<Event>,
480}
481
482#[derive(Debug, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485#[cfg_attr(feature = "ts", ts(export))]
486pub struct StreamEventsResponse {
487 pub accepted: usize,
488}
489
490#[derive(Debug, Serialize, Deserialize)]
494#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
495#[cfg_attr(feature = "ts", ts(export))]
496pub struct HealthResponse {
497 pub status: String,
498 pub version: String,
499}
500
501#[derive(Debug, Clone)]
508#[non_exhaustive]
509pub enum ServiceError {
510 BadRequest(String),
511 Unauthorized(String),
512 Forbidden(String),
513 NotFound(String),
514 Conflict(String),
515 Internal(String),
516}
517
518impl ServiceError {
519 pub fn status_code(&self) -> u16 {
521 match self {
522 Self::BadRequest(_) => 400,
523 Self::Unauthorized(_) => 401,
524 Self::Forbidden(_) => 403,
525 Self::NotFound(_) => 404,
526 Self::Conflict(_) => 409,
527 Self::Internal(_) => 500,
528 }
529 }
530
531 pub fn message(&self) -> &str {
533 match self {
534 Self::BadRequest(m)
535 | Self::Unauthorized(m)
536 | Self::Forbidden(m)
537 | Self::NotFound(m)
538 | Self::Conflict(m)
539 | Self::Internal(m) => m,
540 }
541 }
542
543 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
545 move |e| Self::Internal(format!("{context}: {e}"))
546 }
547}
548
549impl std::fmt::Display for ServiceError {
550 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
551 write!(f, "{}", self.message())
552 }
553}
554
555impl std::error::Error for ServiceError {}
556
557#[derive(Debug, Serialize)]
561#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
562#[cfg_attr(feature = "ts", ts(export))]
563pub struct ApiError {
564 pub error: String,
565}
566
567impl From<&ServiceError> for ApiError {
568 fn from(e: &ServiceError) -> Self {
569 Self {
570 error: e.message().to_string(),
571 }
572 }
573}
574
575#[cfg(all(test, feature = "ts"))]
578mod tests {
579 use super::*;
580 use std::io::Write;
581 use std::path::PathBuf;
582 use ts_rs::TS;
583
584 #[test]
586 fn export_typescript() {
587 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
588 .join("../../packages/ui/src/api-types.generated.ts");
589
590 let cfg = ts_rs::Config::new().with_large_int("number");
591 let mut parts: Vec<String> = Vec::new();
592 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
593 parts.push(
594 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
595 );
596 parts.push(String::new());
597
598 macro_rules! collect_ts {
602 ($($t:ty),+ $(,)?) => {
603 $(
604 let decl = <$t>::decl(&cfg);
605 let decl = if decl.contains(" = {") {
606 decl
608 .replacen("type ", "export interface ", 1)
609 .replace(" = {", " {")
610 .trim_end_matches(';')
611 .to_string()
612 } else {
613 decl
615 .replacen("type ", "export type ", 1)
616 .trim_end_matches(';')
617 .to_string()
618 };
619 parts.push(decl);
620 parts.push(String::new());
621 )+
622 };
623 }
624
625 collect_ts!(
626 SortOrder,
628 TimeRange,
629 LinkType,
630 RegisterRequest,
632 AuthRegisterRequest,
633 LoginRequest,
634 AuthTokenResponse,
635 RefreshRequest,
636 LogoutRequest,
637 ChangePasswordRequest,
638 RegisterResponse,
639 VerifyResponse,
640 UserSettingsResponse,
641 OkResponse,
642 RegenerateKeyResponse,
643 OAuthLinkResponse,
644 UploadResponse,
646 SessionSummary,
647 SessionListResponse,
648 SessionListQuery,
649 SessionDetail,
650 SessionLink,
651 oauth::AuthProvidersResponse,
653 oauth::OAuthProviderInfo,
654 oauth::LinkedProvider,
655 HealthResponse,
657 ApiError,
658 );
659
660 let content = parts.join("\n");
661
662 if let Some(parent) = out_dir.parent() {
664 std::fs::create_dir_all(parent).ok();
665 }
666 let mut file = std::fs::File::create(&out_dir)
667 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
668 file.write_all(content.as_bytes())
669 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
670
671 println!("Generated TypeScript types at: {}", out_dir.display());
672 }
673}