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, Serialize, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct AuthRegisterRequest {
131 pub email: String,
132 pub password: String,
133 pub nickname: String,
134}
135
136#[derive(Debug, Serialize, Deserialize)]
138#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
139#[cfg_attr(feature = "ts", ts(export))]
140pub struct LoginRequest {
141 pub email: String,
142 pub password: String,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
148#[cfg_attr(feature = "ts", ts(export))]
149pub struct AuthTokenResponse {
150 pub access_token: String,
151 pub refresh_token: String,
152 pub expires_in: u64,
153 pub user_id: String,
154 pub nickname: String,
155}
156
157#[derive(Debug, Serialize, Deserialize)]
159#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
160#[cfg_attr(feature = "ts", ts(export))]
161pub struct RefreshRequest {
162 pub refresh_token: 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 LogoutRequest {
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 ChangePasswordRequest {
178 pub current_password: String,
179 pub new_password: String,
180}
181
182#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[cfg_attr(feature = "ts", ts(export))]
186pub struct VerifyResponse {
187 pub user_id: String,
188 pub nickname: String,
189}
190
191#[derive(Debug, Serialize, Deserialize)]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[cfg_attr(feature = "ts", ts(export))]
195pub struct UserSettingsResponse {
196 pub user_id: String,
197 pub nickname: String,
198 pub created_at: String,
199 pub email: Option<String>,
200 pub avatar_url: Option<String>,
201 #[serde(default)]
203 pub oauth_providers: Vec<oauth::LinkedProvider>,
204}
205
206#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[cfg_attr(feature = "ts", ts(export))]
210pub struct OkResponse {
211 pub ok: bool,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
217#[cfg_attr(feature = "ts", ts(export))]
218pub struct IssueApiKeyResponse {
219 pub api_key: String,
220}
221
222#[derive(Debug, Serialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct OAuthLinkResponse {
227 pub url: String,
228}
229
230#[derive(Debug, Serialize, Deserialize)]
234pub struct UploadRequest {
235 pub session: Session,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub body_url: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub linked_session_ids: Option<Vec<String>>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub git_remote: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub git_branch: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub git_commit: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub git_repo_name: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub pr_number: Option<i64>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub pr_url: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub score_plugin: Option<String>,
254}
255
256#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
259#[cfg_attr(feature = "ts", ts(export))]
260pub struct UploadResponse {
261 pub id: String,
262 pub url: String,
263 #[serde(default)]
264 pub session_score: i64,
265 #[serde(default = "default_score_plugin")]
266 pub score_plugin: String,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
272#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
273#[cfg_attr(feature = "ts", ts(export))]
274pub struct SessionSummary {
275 pub id: String,
276 pub user_id: Option<String>,
277 pub nickname: Option<String>,
278 pub tool: String,
279 pub agent_provider: Option<String>,
280 pub agent_model: Option<String>,
281 pub title: Option<String>,
282 pub description: Option<String>,
283 pub tags: Option<String>,
285 pub created_at: String,
286 pub uploaded_at: String,
287 pub message_count: i64,
288 pub task_count: i64,
289 pub event_count: i64,
290 pub duration_seconds: i64,
291 pub total_input_tokens: i64,
292 pub total_output_tokens: i64,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub git_remote: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub git_branch: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub git_commit: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub git_repo_name: Option<String>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub pr_number: Option<i64>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub pr_url: Option<String>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub working_directory: Option<String>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub files_modified: Option<String>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub files_read: Option<String>,
311 #[serde(default)]
312 pub has_errors: bool,
313 #[serde(default = "default_max_active_agents")]
314 pub max_active_agents: i64,
315 #[serde(default)]
316 pub session_score: i64,
317 #[serde(default = "default_score_plugin")]
318 pub score_plugin: String,
319}
320
321#[derive(Debug, Serialize, Deserialize)]
323#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
324#[cfg_attr(feature = "ts", ts(export))]
325pub struct SessionListResponse {
326 pub sessions: Vec<SessionSummary>,
327 pub total: i64,
328 pub page: u32,
329 pub per_page: u32,
330}
331
332#[derive(Debug, Deserialize)]
334#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
335#[cfg_attr(feature = "ts", ts(export))]
336pub struct SessionListQuery {
337 #[serde(default = "default_page")]
338 pub page: u32,
339 #[serde(default = "default_per_page")]
340 pub per_page: u32,
341 pub search: Option<String>,
342 pub tool: Option<String>,
343 pub sort: Option<SortOrder>,
345 pub time_range: Option<TimeRange>,
347}
348
349impl SessionListQuery {
350 pub fn is_public_feed_cacheable(
352 &self,
353 has_auth_header: bool,
354 has_session_cookie: bool,
355 ) -> bool {
356 !has_auth_header
357 && !has_session_cookie
358 && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
359 && self.page <= 10
360 && self.per_page <= 50
361 }
362}
363
364#[cfg(test)]
365mod session_list_query_tests {
366 use super::*;
367
368 fn base_query() -> SessionListQuery {
369 SessionListQuery {
370 page: 1,
371 per_page: 20,
372 search: None,
373 tool: None,
374 sort: None,
375 time_range: None,
376 }
377 }
378
379 #[test]
380 fn public_feed_cacheable_when_anonymous_default_feed() {
381 let q = base_query();
382 assert!(q.is_public_feed_cacheable(false, false));
383 }
384
385 #[test]
386 fn public_feed_not_cacheable_with_auth_or_cookie() {
387 let q = base_query();
388 assert!(!q.is_public_feed_cacheable(true, false));
389 assert!(!q.is_public_feed_cacheable(false, true));
390 }
391
392 #[test]
393 fn public_feed_not_cacheable_for_search_or_large_page() {
394 let mut q = base_query();
395 q.search = Some("hello".into());
396 assert!(!q.is_public_feed_cacheable(false, false));
397
398 let mut q = base_query();
399 q.page = 11;
400 assert!(!q.is_public_feed_cacheable(false, false));
401
402 let mut q = base_query();
403 q.per_page = 100;
404 assert!(!q.is_public_feed_cacheable(false, false));
405 }
406}
407
408fn default_page() -> u32 {
409 1
410}
411fn default_per_page() -> u32 {
412 20
413}
414fn default_max_active_agents() -> i64 {
415 1
416}
417
418fn default_score_plugin() -> String {
419 opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
420}
421
422#[derive(Debug, Serialize, Deserialize)]
424#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
425#[cfg_attr(feature = "ts", ts(export))]
426pub struct SessionDetail {
427 #[serde(flatten)]
428 #[cfg_attr(feature = "ts", ts(flatten))]
429 pub summary: SessionSummary,
430 #[serde(default, skip_serializing_if = "Vec::is_empty")]
431 pub linked_sessions: Vec<SessionLink>,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
436#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
437#[cfg_attr(feature = "ts", ts(export))]
438pub struct SessionLink {
439 pub session_id: String,
440 pub linked_session_id: String,
441 pub link_type: LinkType,
442 pub created_at: String,
443}
444
445#[derive(Debug, Deserialize)]
449#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
450#[cfg_attr(feature = "ts", ts(export))]
451pub struct StreamEventsRequest {
452 #[cfg_attr(feature = "ts", ts(type = "any"))]
453 pub agent: Option<Agent>,
454 #[cfg_attr(feature = "ts", ts(type = "any"))]
455 pub context: Option<SessionContext>,
456 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
457 pub events: Vec<Event>,
458}
459
460#[derive(Debug, Serialize, Deserialize)]
462#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
463#[cfg_attr(feature = "ts", ts(export))]
464pub struct StreamEventsResponse {
465 pub accepted: usize,
466}
467
468#[derive(Debug, Serialize, Deserialize)]
472#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
473#[cfg_attr(feature = "ts", ts(export))]
474pub struct HealthResponse {
475 pub status: String,
476 pub version: String,
477}
478
479#[derive(Debug, Serialize, Deserialize)]
481#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
482#[cfg_attr(feature = "ts", ts(export))]
483pub struct CapabilitiesResponse {
484 pub auth_enabled: bool,
485 pub upload_enabled: bool,
486}
487
488#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub enum ServiceError {
497 BadRequest(String),
498 Unauthorized(String),
499 Forbidden(String),
500 NotFound(String),
501 Conflict(String),
502 Internal(String),
503}
504
505impl ServiceError {
506 pub fn status_code(&self) -> u16 {
508 match self {
509 Self::BadRequest(_) => 400,
510 Self::Unauthorized(_) => 401,
511 Self::Forbidden(_) => 403,
512 Self::NotFound(_) => 404,
513 Self::Conflict(_) => 409,
514 Self::Internal(_) => 500,
515 }
516 }
517
518 pub fn code(&self) -> &'static str {
520 match self {
521 Self::BadRequest(_) => "bad_request",
522 Self::Unauthorized(_) => "unauthorized",
523 Self::Forbidden(_) => "forbidden",
524 Self::NotFound(_) => "not_found",
525 Self::Conflict(_) => "conflict",
526 Self::Internal(_) => "internal",
527 }
528 }
529
530 pub fn message(&self) -> &str {
532 match self {
533 Self::BadRequest(m)
534 | Self::Unauthorized(m)
535 | Self::Forbidden(m)
536 | Self::NotFound(m)
537 | Self::Conflict(m)
538 | Self::Internal(m) => m,
539 }
540 }
541
542 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
544 move |e| Self::Internal(format!("{context}: {e}"))
545 }
546}
547
548impl std::fmt::Display for ServiceError {
549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
550 write!(f, "{}", self.message())
551 }
552}
553
554impl std::error::Error for ServiceError {}
555
556#[derive(Debug, Serialize)]
560#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
561#[cfg_attr(feature = "ts", ts(export))]
562pub struct ApiError {
563 pub code: String,
564 pub message: String,
565}
566
567impl From<&ServiceError> for ApiError {
568 fn from(e: &ServiceError) -> Self {
569 Self {
570 code: e.code().to_string(),
571 message: e.message().to_string(),
572 }
573 }
574}
575
576#[cfg(all(test, feature = "ts"))]
579mod tests {
580 use super::*;
581 use std::io::Write;
582 use std::path::PathBuf;
583 use ts_rs::TS;
584
585 #[test]
587 fn export_typescript() {
588 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
589 .join("../../packages/ui/src/api-types.generated.ts");
590
591 let cfg = ts_rs::Config::new().with_large_int("number");
592 let mut parts: Vec<String> = Vec::new();
593 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
594 parts.push(
595 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
596 );
597 parts.push(String::new());
598
599 macro_rules! collect_ts {
603 ($($t:ty),+ $(,)?) => {
604 $(
605 let decl = <$t>::decl(&cfg);
606 let decl = if decl.contains(" = {") {
607 decl
609 .replacen("type ", "export interface ", 1)
610 .replace(" = {", " {")
611 .trim_end_matches(';')
612 .to_string()
613 } else {
614 decl
616 .replacen("type ", "export type ", 1)
617 .trim_end_matches(';')
618 .to_string()
619 };
620 parts.push(decl);
621 parts.push(String::new());
622 )+
623 };
624 }
625
626 collect_ts!(
627 SortOrder,
629 TimeRange,
630 LinkType,
631 AuthRegisterRequest,
633 LoginRequest,
634 AuthTokenResponse,
635 RefreshRequest,
636 LogoutRequest,
637 ChangePasswordRequest,
638 VerifyResponse,
639 UserSettingsResponse,
640 OkResponse,
641 IssueApiKeyResponse,
642 OAuthLinkResponse,
643 UploadResponse,
645 SessionSummary,
646 SessionListResponse,
647 SessionListQuery,
648 SessionDetail,
649 SessionLink,
650 oauth::AuthProvidersResponse,
652 oauth::OAuthProviderInfo,
653 oauth::LinkedProvider,
654 HealthResponse,
656 CapabilitiesResponse,
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}