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, Deserialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct GitCredentialSummary {
227 pub id: String,
228 pub label: String,
229 pub host: String,
230 pub path_prefix: String,
231 pub header_name: String,
232 pub created_at: String,
233 pub updated_at: String,
234 pub last_used_at: Option<String>,
235}
236
237#[derive(Debug, Serialize, Deserialize)]
239#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
240#[cfg_attr(feature = "ts", ts(export))]
241pub struct ListGitCredentialsResponse {
242 #[serde(default)]
243 pub credentials: Vec<GitCredentialSummary>,
244}
245
246#[derive(Debug, Serialize, Deserialize)]
248#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
249#[cfg_attr(feature = "ts", ts(export))]
250pub struct CreateGitCredentialRequest {
251 pub label: String,
252 pub host: String,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub path_prefix: Option<String>,
255 pub header_name: String,
256 pub header_value: String,
257}
258
259#[derive(Debug, Serialize)]
261#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
262#[cfg_attr(feature = "ts", ts(export))]
263pub struct OAuthLinkResponse {
264 pub url: String,
265}
266
267#[derive(Debug, Serialize, Deserialize)]
271pub struct UploadRequest {
272 pub session: Session,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub body_url: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub linked_session_ids: Option<Vec<String>>,
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub git_remote: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub git_branch: Option<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub git_commit: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub git_repo_name: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub pr_number: Option<i64>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub pr_url: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub score_plugin: Option<String>,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
295#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
296#[cfg_attr(feature = "ts", ts(export))]
297pub struct UploadResponse {
298 pub id: String,
299 pub url: String,
300 #[serde(default)]
301 pub session_score: i64,
302 #[serde(default = "default_score_plugin")]
303 pub score_plugin: String,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
310#[cfg_attr(feature = "ts", ts(export))]
311pub struct SessionSummary {
312 pub id: String,
313 pub user_id: Option<String>,
314 pub nickname: Option<String>,
315 pub tool: String,
316 pub agent_provider: Option<String>,
317 pub agent_model: Option<String>,
318 pub title: Option<String>,
319 pub description: Option<String>,
320 pub tags: Option<String>,
322 pub created_at: String,
323 pub uploaded_at: String,
324 pub message_count: i64,
325 pub task_count: i64,
326 pub event_count: i64,
327 pub duration_seconds: i64,
328 pub total_input_tokens: i64,
329 pub total_output_tokens: i64,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub git_remote: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub git_branch: Option<String>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub git_commit: Option<String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub git_repo_name: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub pr_number: Option<i64>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub pr_url: Option<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub working_directory: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub files_modified: Option<String>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub files_read: Option<String>,
348 #[serde(default)]
349 pub has_errors: bool,
350 #[serde(default = "default_max_active_agents")]
351 pub max_active_agents: i64,
352 #[serde(default)]
353 pub session_score: i64,
354 #[serde(default = "default_score_plugin")]
355 pub score_plugin: String,
356}
357
358#[derive(Debug, Serialize, Deserialize)]
360#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
361#[cfg_attr(feature = "ts", ts(export))]
362pub struct SessionListResponse {
363 pub sessions: Vec<SessionSummary>,
364 pub total: i64,
365 pub page: u32,
366 pub per_page: u32,
367}
368
369pub const DESKTOP_IPC_CONTRACT_VERSION: &str = "desktop-ipc-v1";
371
372#[derive(Debug, Deserialize)]
374#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
375#[cfg_attr(feature = "ts", ts(export))]
376pub struct SessionListQuery {
377 #[serde(default = "default_page")]
378 pub page: u32,
379 #[serde(default = "default_per_page")]
380 pub per_page: u32,
381 pub search: Option<String>,
382 pub tool: Option<String>,
383 pub git_repo_name: Option<String>,
384 pub sort: Option<SortOrder>,
386 pub time_range: Option<TimeRange>,
388}
389
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
393#[cfg_attr(feature = "ts", ts(export))]
394pub struct DesktopSessionListQuery {
395 pub page: Option<String>,
396 pub per_page: Option<String>,
397 pub search: Option<String>,
398 pub tool: Option<String>,
399 pub git_repo_name: Option<String>,
400 pub sort: Option<String>,
401 pub time_range: Option<String>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
407#[cfg_attr(feature = "ts", ts(export))]
408pub struct SessionRepoListResponse {
409 pub repos: Vec<String>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
415#[cfg_attr(feature = "ts", ts(export))]
416pub struct DesktopHandoffBuildRequest {
417 pub session_id: String,
418 pub pin_latest: bool,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
424#[cfg_attr(feature = "ts", ts(export))]
425pub struct DesktopHandoffBuildResponse {
426 pub artifact_uri: String,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub pinned_alias: Option<String>,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
433#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
434#[cfg_attr(feature = "ts", ts(export))]
435pub struct DesktopContractVersionResponse {
436 pub version: String,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize)]
441#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
442#[cfg_attr(feature = "ts", ts(export))]
443pub struct DesktopApiError {
444 pub code: String,
445 pub status: u16,
446 pub message: String,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 #[cfg_attr(feature = "ts", ts(type = "Record<string, any> | null"))]
449 pub details: Option<serde_json::Value>,
450}
451
452impl SessionListQuery {
453 pub fn is_public_feed_cacheable(
455 &self,
456 has_auth_header: bool,
457 has_session_cookie: bool,
458 ) -> bool {
459 !has_auth_header
460 && !has_session_cookie
461 && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
462 && self
463 .git_repo_name
464 .as_deref()
465 .is_none_or(|repo| repo.trim().is_empty())
466 && self.page <= 10
467 && self.per_page <= 50
468 }
469}
470
471#[cfg(test)]
472mod session_list_query_tests {
473 use super::*;
474
475 fn base_query() -> SessionListQuery {
476 SessionListQuery {
477 page: 1,
478 per_page: 20,
479 search: None,
480 tool: None,
481 git_repo_name: None,
482 sort: None,
483 time_range: None,
484 }
485 }
486
487 #[test]
488 fn public_feed_cacheable_when_anonymous_default_feed() {
489 let q = base_query();
490 assert!(q.is_public_feed_cacheable(false, false));
491 }
492
493 #[test]
494 fn public_feed_not_cacheable_with_auth_or_cookie() {
495 let q = base_query();
496 assert!(!q.is_public_feed_cacheable(true, false));
497 assert!(!q.is_public_feed_cacheable(false, true));
498 }
499
500 #[test]
501 fn public_feed_not_cacheable_for_search_or_large_page() {
502 let mut q = base_query();
503 q.search = Some("hello".into());
504 assert!(!q.is_public_feed_cacheable(false, false));
505
506 let mut q = base_query();
507 q.git_repo_name = Some("org/repo".into());
508 assert!(!q.is_public_feed_cacheable(false, false));
509
510 let mut q = base_query();
511 q.page = 11;
512 assert!(!q.is_public_feed_cacheable(false, false));
513
514 let mut q = base_query();
515 q.per_page = 100;
516 assert!(!q.is_public_feed_cacheable(false, false));
517 }
518}
519
520fn default_page() -> u32 {
521 1
522}
523fn default_per_page() -> u32 {
524 20
525}
526fn default_max_active_agents() -> i64 {
527 1
528}
529
530fn default_score_plugin() -> String {
531 opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
532}
533
534#[derive(Debug, Serialize, Deserialize)]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[cfg_attr(feature = "ts", ts(export))]
538pub struct SessionDetail {
539 #[serde(flatten)]
540 #[cfg_attr(feature = "ts", ts(flatten))]
541 pub summary: SessionSummary,
542 #[serde(default, skip_serializing_if = "Vec::is_empty")]
543 pub linked_sessions: Vec<SessionLink>,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
548#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
549#[cfg_attr(feature = "ts", ts(export))]
550pub struct SessionLink {
551 pub session_id: String,
552 pub linked_session_id: String,
553 pub link_type: LinkType,
554 pub created_at: String,
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
559#[serde(tag = "kind", rename_all = "snake_case")]
560#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
561#[cfg_attr(feature = "ts", ts(export))]
562pub enum ParseSource {
563 Git {
565 remote: String,
566 r#ref: String,
567 path: String,
568 },
569 Github {
571 owner: String,
572 repo: String,
573 r#ref: String,
574 path: String,
575 },
576 Inline {
578 filename: String,
579 content_base64: String,
581 },
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
587#[cfg_attr(feature = "ts", ts(export))]
588pub struct ParseCandidate {
589 pub id: String,
590 pub confidence: u8,
591 pub reason: String,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize)]
596#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
597#[cfg_attr(feature = "ts", ts(export))]
598pub struct ParsePreviewRequest {
599 pub source: ParseSource,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 pub parser_hint: Option<String>,
602}
603
604#[derive(Debug, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
607#[cfg_attr(feature = "ts", ts(export))]
608pub struct ParsePreviewResponse {
609 pub parser_used: String,
610 #[serde(default)]
611 pub parser_candidates: Vec<ParseCandidate>,
612 #[cfg_attr(feature = "ts", ts(type = "any"))]
613 pub session: Session,
614 pub source: ParseSource,
615 #[serde(default)]
616 pub warnings: Vec<String>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub native_adapter: Option<String>,
619}
620
621#[derive(Debug, Clone, Serialize, Deserialize)]
623#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
624#[cfg_attr(feature = "ts", ts(export))]
625pub struct ParsePreviewErrorResponse {
626 pub code: String,
627 pub message: String,
628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
629 pub parser_candidates: Vec<ParseCandidate>,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
634#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
635#[cfg_attr(feature = "ts", ts(export))]
636pub struct LocalReviewBundle {
637 pub review_id: String,
638 pub generated_at: String,
639 pub pr: LocalReviewPrMeta,
640 #[serde(default)]
641 pub commits: Vec<LocalReviewCommit>,
642 #[serde(default)]
643 pub sessions: Vec<LocalReviewSession>,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize)]
648#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
649#[cfg_attr(feature = "ts", ts(export))]
650pub struct LocalReviewPrMeta {
651 pub url: String,
652 pub owner: String,
653 pub repo: String,
654 pub number: u64,
655 pub remote: String,
656 pub base_sha: String,
657 pub head_sha: String,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
663#[cfg_attr(feature = "ts", ts(export))]
664pub struct LocalReviewCommit {
665 pub sha: String,
666 pub title: String,
667 pub author_name: String,
668 pub author_email: String,
669 pub authored_at: String,
670 #[serde(default)]
671 pub session_ids: Vec<String>,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize)]
676#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
677#[cfg_attr(feature = "ts", ts(export))]
678pub struct LocalReviewSession {
679 pub session_id: String,
680 pub ledger_ref: String,
681 pub hail_path: String,
682 #[serde(default)]
683 pub commit_shas: Vec<String>,
684 #[cfg_attr(feature = "ts", ts(type = "any"))]
685 pub session: Session,
686}
687
688#[derive(Debug, Deserialize)]
692#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
693#[cfg_attr(feature = "ts", ts(export))]
694pub struct StreamEventsRequest {
695 #[cfg_attr(feature = "ts", ts(type = "any"))]
696 pub agent: Option<Agent>,
697 #[cfg_attr(feature = "ts", ts(type = "any"))]
698 pub context: Option<SessionContext>,
699 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
700 pub events: Vec<Event>,
701}
702
703#[derive(Debug, Serialize, Deserialize)]
705#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
706#[cfg_attr(feature = "ts", ts(export))]
707pub struct StreamEventsResponse {
708 pub accepted: usize,
709}
710
711#[derive(Debug, Serialize, Deserialize)]
715#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
716#[cfg_attr(feature = "ts", ts(export))]
717pub struct HealthResponse {
718 pub status: String,
719 pub version: String,
720}
721
722#[derive(Debug, Serialize, Deserialize)]
724#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
725#[cfg_attr(feature = "ts", ts(export))]
726pub struct CapabilitiesResponse {
727 pub auth_enabled: bool,
728 pub parse_preview_enabled: bool,
729 pub register_targets: Vec<String>,
730 pub share_modes: Vec<String>,
731}
732
733pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
734pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
735
736impl CapabilitiesResponse {
737 pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
739 Self {
740 auth_enabled,
741 parse_preview_enabled,
742 register_targets: DEFAULT_REGISTER_TARGETS
743 .iter()
744 .map(|target| (*target).to_string())
745 .collect(),
746 share_modes: DEFAULT_SHARE_MODES
747 .iter()
748 .map(|mode| (*mode).to_string())
749 .collect(),
750 }
751 }
752}
753
754#[derive(Debug, Clone)]
761#[non_exhaustive]
762pub enum ServiceError {
763 BadRequest(String),
764 Unauthorized(String),
765 Forbidden(String),
766 NotFound(String),
767 Conflict(String),
768 Internal(String),
769}
770
771impl ServiceError {
772 pub fn status_code(&self) -> u16 {
774 match self {
775 Self::BadRequest(_) => 400,
776 Self::Unauthorized(_) => 401,
777 Self::Forbidden(_) => 403,
778 Self::NotFound(_) => 404,
779 Self::Conflict(_) => 409,
780 Self::Internal(_) => 500,
781 }
782 }
783
784 pub fn code(&self) -> &'static str {
786 match self {
787 Self::BadRequest(_) => "bad_request",
788 Self::Unauthorized(_) => "unauthorized",
789 Self::Forbidden(_) => "forbidden",
790 Self::NotFound(_) => "not_found",
791 Self::Conflict(_) => "conflict",
792 Self::Internal(_) => "internal",
793 }
794 }
795
796 pub fn message(&self) -> &str {
798 match self {
799 Self::BadRequest(m)
800 | Self::Unauthorized(m)
801 | Self::Forbidden(m)
802 | Self::NotFound(m)
803 | Self::Conflict(m)
804 | Self::Internal(m) => m,
805 }
806 }
807
808 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
810 move |e| Self::Internal(format!("{context}: {e}"))
811 }
812}
813
814impl std::fmt::Display for ServiceError {
815 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
816 write!(f, "{}", self.message())
817 }
818}
819
820impl std::error::Error for ServiceError {}
821
822#[derive(Debug, Serialize)]
826#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
827#[cfg_attr(feature = "ts", ts(export))]
828pub struct ApiError {
829 pub code: String,
830 pub message: String,
831}
832
833impl From<&ServiceError> for ApiError {
834 fn from(e: &ServiceError) -> Self {
835 Self {
836 code: e.code().to_string(),
837 message: e.message().to_string(),
838 }
839 }
840}
841
842#[cfg(test)]
845mod schema_tests {
846 use super::*;
847
848 #[test]
849 fn parse_preview_request_round_trip_git() {
850 let req = ParsePreviewRequest {
851 source: ParseSource::Git {
852 remote: "https://github.com/hwisu/opensession".to_string(),
853 r#ref: "main".to_string(),
854 path: "sessions/demo.hail.jsonl".to_string(),
855 },
856 parser_hint: Some("hail".to_string()),
857 };
858
859 let json = serde_json::to_string(&req).expect("request should serialize");
860 let decoded: ParsePreviewRequest =
861 serde_json::from_str(&json).expect("request should deserialize");
862
863 match decoded.source {
864 ParseSource::Git {
865 remote,
866 r#ref,
867 path,
868 } => {
869 assert_eq!(remote, "https://github.com/hwisu/opensession");
870 assert_eq!(r#ref, "main");
871 assert_eq!(path, "sessions/demo.hail.jsonl");
872 }
873 _ => panic!("expected git parse source"),
874 }
875 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
876 }
877
878 #[test]
879 fn parse_preview_request_round_trip_github_compat() {
880 let req = ParsePreviewRequest {
881 source: ParseSource::Github {
882 owner: "hwisu".to_string(),
883 repo: "opensession".to_string(),
884 r#ref: "main".to_string(),
885 path: "sessions/demo.hail.jsonl".to_string(),
886 },
887 parser_hint: Some("hail".to_string()),
888 };
889
890 let json = serde_json::to_string(&req).expect("request should serialize");
891 let decoded: ParsePreviewRequest =
892 serde_json::from_str(&json).expect("request should deserialize");
893
894 match decoded.source {
895 ParseSource::Github {
896 owner,
897 repo,
898 r#ref,
899 path,
900 } => {
901 assert_eq!(owner, "hwisu");
902 assert_eq!(repo, "opensession");
903 assert_eq!(r#ref, "main");
904 assert_eq!(path, "sessions/demo.hail.jsonl");
905 }
906 _ => panic!("expected github parse source"),
907 }
908 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
909 }
910
911 #[test]
912 fn parse_preview_error_response_round_trip_with_candidates() {
913 let payload = ParsePreviewErrorResponse {
914 code: "parser_selection_required".to_string(),
915 message: "choose parser".to_string(),
916 parser_candidates: vec![ParseCandidate {
917 id: "codex".to_string(),
918 confidence: 89,
919 reason: "event markers".to_string(),
920 }],
921 };
922
923 let json = serde_json::to_string(&payload).expect("error payload should serialize");
924 let decoded: ParsePreviewErrorResponse =
925 serde_json::from_str(&json).expect("error payload should deserialize");
926
927 assert_eq!(decoded.code, "parser_selection_required");
928 assert_eq!(decoded.parser_candidates.len(), 1);
929 assert_eq!(decoded.parser_candidates[0].id, "codex");
930 }
931
932 #[test]
933 fn local_review_bundle_round_trip() {
934 let mut sample_session = Session::new(
935 "s-review-1".to_string(),
936 Agent {
937 provider: "openai".to_string(),
938 model: "gpt-5".to_string(),
939 tool: "codex".to_string(),
940 tool_version: None,
941 },
942 );
943 sample_session.recompute_stats();
944
945 let payload = LocalReviewBundle {
946 review_id: "gh-org-repo-pr1-abc1234".to_string(),
947 generated_at: "2026-02-24T00:00:00Z".to_string(),
948 pr: LocalReviewPrMeta {
949 url: "https://github.com/org/repo/pull/1".to_string(),
950 owner: "org".to_string(),
951 repo: "repo".to_string(),
952 number: 1,
953 remote: "origin".to_string(),
954 base_sha: "a".repeat(40),
955 head_sha: "b".repeat(40),
956 },
957 commits: vec![LocalReviewCommit {
958 sha: "c".repeat(40),
959 title: "feat: add review flow".to_string(),
960 author_name: "Alice".to_string(),
961 author_email: "alice@example.com".to_string(),
962 authored_at: "2026-02-24T00:00:00Z".to_string(),
963 session_ids: vec!["s-review-1".to_string()],
964 }],
965 sessions: vec![LocalReviewSession {
966 session_id: "s-review-1".to_string(),
967 ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
968 hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
969 commit_shas: vec!["c".repeat(40)],
970 session: sample_session,
971 }],
972 };
973
974 let json = serde_json::to_string(&payload).expect("review bundle should serialize");
975 let decoded: LocalReviewBundle =
976 serde_json::from_str(&json).expect("review bundle should deserialize");
977
978 assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
979 assert_eq!(decoded.pr.number, 1);
980 assert_eq!(decoded.commits.len(), 1);
981 assert_eq!(decoded.sessions.len(), 1);
982 assert_eq!(decoded.sessions[0].session_id, "s-review-1");
983 }
984
985 #[test]
986 fn capabilities_response_round_trip_includes_new_fields() {
987 let caps = CapabilitiesResponse::for_runtime(true, true);
988
989 let json = serde_json::to_string(&caps).expect("capabilities should serialize");
990 let decoded: CapabilitiesResponse =
991 serde_json::from_str(&json).expect("capabilities should deserialize");
992
993 assert!(decoded.auth_enabled);
994 assert!(decoded.parse_preview_enabled);
995 assert_eq!(decoded.register_targets, vec!["local", "git"]);
996 assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
997 }
998
999 #[test]
1000 fn capabilities_defaults_are_stable() {
1001 assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
1002 assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
1003 }
1004}
1005
1006#[cfg(all(test, feature = "ts"))]
1007mod tests {
1008 use super::*;
1009 use std::io::Write;
1010 use std::path::PathBuf;
1011 use ts_rs::TS;
1012
1013 #[test]
1015 fn export_typescript() {
1016 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1017 .join("../../packages/ui/src/api-types.generated.ts");
1018
1019 let cfg = ts_rs::Config::new().with_large_int("number");
1020 let mut parts: Vec<String> = Vec::new();
1021 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
1022 parts.push(
1023 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
1024 );
1025 parts.push(String::new());
1026
1027 macro_rules! collect_ts {
1031 ($($t:ty),+ $(,)?) => {
1032 $(
1033 let decl = <$t>::decl(&cfg);
1034 let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
1035 let decl = if is_struct_decl {
1036 decl
1038 .replacen("type ", "export interface ", 1)
1039 .replace(" = {", " {")
1040 .trim_end_matches(';')
1041 .to_string()
1042 } else {
1043 decl
1045 .replacen("type ", "export type ", 1)
1046 .trim_end_matches(';')
1047 .to_string()
1048 };
1049 parts.push(decl);
1050 parts.push(String::new());
1051 )+
1052 };
1053 }
1054
1055 collect_ts!(
1056 SortOrder,
1058 TimeRange,
1059 LinkType,
1060 AuthRegisterRequest,
1062 LoginRequest,
1063 AuthTokenResponse,
1064 RefreshRequest,
1065 LogoutRequest,
1066 ChangePasswordRequest,
1067 VerifyResponse,
1068 UserSettingsResponse,
1069 OkResponse,
1070 IssueApiKeyResponse,
1071 GitCredentialSummary,
1072 ListGitCredentialsResponse,
1073 CreateGitCredentialRequest,
1074 OAuthLinkResponse,
1075 UploadResponse,
1077 SessionSummary,
1078 SessionListResponse,
1079 SessionListQuery,
1080 DesktopSessionListQuery,
1081 SessionRepoListResponse,
1082 DesktopHandoffBuildRequest,
1083 DesktopHandoffBuildResponse,
1084 DesktopContractVersionResponse,
1085 DesktopApiError,
1086 SessionDetail,
1087 SessionLink,
1088 ParseSource,
1089 ParseCandidate,
1090 ParsePreviewRequest,
1091 ParsePreviewResponse,
1092 ParsePreviewErrorResponse,
1093 LocalReviewBundle,
1094 LocalReviewPrMeta,
1095 LocalReviewCommit,
1096 LocalReviewSession,
1097 oauth::AuthProvidersResponse,
1099 oauth::OAuthProviderInfo,
1100 oauth::LinkedProvider,
1101 HealthResponse,
1103 CapabilitiesResponse,
1104 ApiError,
1105 );
1106
1107 let content = parts.join("\n");
1108
1109 if let Some(parent) = out_dir.parent() {
1111 std::fs::create_dir_all(parent).ok();
1112 }
1113 let mut file = std::fs::File::create(&out_dir)
1114 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1115 file.write_all(content.as_bytes())
1116 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1117
1118 println!("Generated TypeScript types at: {}", out_dir.display());
1119 }
1120}