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, Serialize, Deserialize)]
503#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
504#[cfg_attr(feature = "ts", ts(export))]
505pub struct CapabilitiesResponse {
506 pub auth_enabled: bool,
507 pub upload_enabled: bool,
508}
509
510#[derive(Debug, Clone)]
517#[non_exhaustive]
518pub enum ServiceError {
519 BadRequest(String),
520 Unauthorized(String),
521 Forbidden(String),
522 NotFound(String),
523 Conflict(String),
524 Internal(String),
525}
526
527impl ServiceError {
528 pub fn status_code(&self) -> u16 {
530 match self {
531 Self::BadRequest(_) => 400,
532 Self::Unauthorized(_) => 401,
533 Self::Forbidden(_) => 403,
534 Self::NotFound(_) => 404,
535 Self::Conflict(_) => 409,
536 Self::Internal(_) => 500,
537 }
538 }
539
540 pub fn message(&self) -> &str {
542 match self {
543 Self::BadRequest(m)
544 | Self::Unauthorized(m)
545 | Self::Forbidden(m)
546 | Self::NotFound(m)
547 | Self::Conflict(m)
548 | Self::Internal(m) => m,
549 }
550 }
551
552 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
554 move |e| Self::Internal(format!("{context}: {e}"))
555 }
556}
557
558impl std::fmt::Display for ServiceError {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 write!(f, "{}", self.message())
561 }
562}
563
564impl std::error::Error for ServiceError {}
565
566#[derive(Debug, Serialize)]
570#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
571#[cfg_attr(feature = "ts", ts(export))]
572pub struct ApiError {
573 pub error: String,
574}
575
576impl From<&ServiceError> for ApiError {
577 fn from(e: &ServiceError) -> Self {
578 Self {
579 error: e.message().to_string(),
580 }
581 }
582}
583
584#[cfg(all(test, feature = "ts"))]
587mod tests {
588 use super::*;
589 use std::io::Write;
590 use std::path::PathBuf;
591 use ts_rs::TS;
592
593 #[test]
595 fn export_typescript() {
596 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
597 .join("../../packages/ui/src/api-types.generated.ts");
598
599 let cfg = ts_rs::Config::new().with_large_int("number");
600 let mut parts: Vec<String> = Vec::new();
601 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
602 parts.push(
603 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
604 );
605 parts.push(String::new());
606
607 macro_rules! collect_ts {
611 ($($t:ty),+ $(,)?) => {
612 $(
613 let decl = <$t>::decl(&cfg);
614 let decl = if decl.contains(" = {") {
615 decl
617 .replacen("type ", "export interface ", 1)
618 .replace(" = {", " {")
619 .trim_end_matches(';')
620 .to_string()
621 } else {
622 decl
624 .replacen("type ", "export type ", 1)
625 .trim_end_matches(';')
626 .to_string()
627 };
628 parts.push(decl);
629 parts.push(String::new());
630 )+
631 };
632 }
633
634 collect_ts!(
635 SortOrder,
637 TimeRange,
638 LinkType,
639 RegisterRequest,
641 AuthRegisterRequest,
642 LoginRequest,
643 AuthTokenResponse,
644 RefreshRequest,
645 LogoutRequest,
646 ChangePasswordRequest,
647 RegisterResponse,
648 VerifyResponse,
649 UserSettingsResponse,
650 OkResponse,
651 RegenerateKeyResponse,
652 OAuthLinkResponse,
653 UploadResponse,
655 SessionSummary,
656 SessionListResponse,
657 SessionListQuery,
658 SessionDetail,
659 SessionLink,
660 oauth::AuthProvidersResponse,
662 oauth::OAuthProviderInfo,
663 oauth::LinkedProvider,
664 HealthResponse,
666 CapabilitiesResponse,
667 ApiError,
668 );
669
670 let content = parts.join("\n");
671
672 if let Some(parent) = out_dir.parent() {
674 std::fs::create_dir_all(parent).ok();
675 }
676 let mut file = std::fs::File::create(&out_dir)
677 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
678 file.write_all(content.as_bytes())
679 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
680
681 println!("Generated TypeScript types at: {}", out_dir.display());
682 }
683}