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
369#[derive(Debug, Deserialize)]
371#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
372#[cfg_attr(feature = "ts", ts(export))]
373pub struct SessionListQuery {
374 #[serde(default = "default_page")]
375 pub page: u32,
376 #[serde(default = "default_per_page")]
377 pub per_page: u32,
378 pub search: Option<String>,
379 pub tool: Option<String>,
380 pub sort: Option<SortOrder>,
382 pub time_range: Option<TimeRange>,
384}
385
386impl SessionListQuery {
387 pub fn is_public_feed_cacheable(
389 &self,
390 has_auth_header: bool,
391 has_session_cookie: bool,
392 ) -> bool {
393 !has_auth_header
394 && !has_session_cookie
395 && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
396 && self.page <= 10
397 && self.per_page <= 50
398 }
399}
400
401#[cfg(test)]
402mod session_list_query_tests {
403 use super::*;
404
405 fn base_query() -> SessionListQuery {
406 SessionListQuery {
407 page: 1,
408 per_page: 20,
409 search: None,
410 tool: None,
411 sort: None,
412 time_range: None,
413 }
414 }
415
416 #[test]
417 fn public_feed_cacheable_when_anonymous_default_feed() {
418 let q = base_query();
419 assert!(q.is_public_feed_cacheable(false, false));
420 }
421
422 #[test]
423 fn public_feed_not_cacheable_with_auth_or_cookie() {
424 let q = base_query();
425 assert!(!q.is_public_feed_cacheable(true, false));
426 assert!(!q.is_public_feed_cacheable(false, true));
427 }
428
429 #[test]
430 fn public_feed_not_cacheable_for_search_or_large_page() {
431 let mut q = base_query();
432 q.search = Some("hello".into());
433 assert!(!q.is_public_feed_cacheable(false, false));
434
435 let mut q = base_query();
436 q.page = 11;
437 assert!(!q.is_public_feed_cacheable(false, false));
438
439 let mut q = base_query();
440 q.per_page = 100;
441 assert!(!q.is_public_feed_cacheable(false, false));
442 }
443}
444
445fn default_page() -> u32 {
446 1
447}
448fn default_per_page() -> u32 {
449 20
450}
451fn default_max_active_agents() -> i64 {
452 1
453}
454
455fn default_score_plugin() -> String {
456 opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
457}
458
459#[derive(Debug, Serialize, Deserialize)]
461#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
462#[cfg_attr(feature = "ts", ts(export))]
463pub struct SessionDetail {
464 #[serde(flatten)]
465 #[cfg_attr(feature = "ts", ts(flatten))]
466 pub summary: SessionSummary,
467 #[serde(default, skip_serializing_if = "Vec::is_empty")]
468 pub linked_sessions: Vec<SessionLink>,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
474#[cfg_attr(feature = "ts", ts(export))]
475pub struct SessionLink {
476 pub session_id: String,
477 pub linked_session_id: String,
478 pub link_type: LinkType,
479 pub created_at: String,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(tag = "kind", rename_all = "snake_case")]
485#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
486#[cfg_attr(feature = "ts", ts(export))]
487pub enum ParseSource {
488 Git {
490 remote: String,
491 r#ref: String,
492 path: String,
493 },
494 Github {
496 owner: String,
497 repo: String,
498 r#ref: String,
499 path: String,
500 },
501 Inline {
503 filename: String,
504 content_base64: String,
506 },
507}
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
512#[cfg_attr(feature = "ts", ts(export))]
513pub struct ParseCandidate {
514 pub id: String,
515 pub confidence: u8,
516 pub reason: String,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
521#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
522#[cfg_attr(feature = "ts", ts(export))]
523pub struct ParsePreviewRequest {
524 pub source: ParseSource,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub parser_hint: Option<String>,
527}
528
529#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
532#[cfg_attr(feature = "ts", ts(export))]
533pub struct ParsePreviewResponse {
534 pub parser_used: String,
535 #[serde(default)]
536 pub parser_candidates: Vec<ParseCandidate>,
537 #[cfg_attr(feature = "ts", ts(type = "any"))]
538 pub session: Session,
539 pub source: ParseSource,
540 #[serde(default)]
541 pub warnings: Vec<String>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub native_adapter: Option<String>,
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 ParsePreviewErrorResponse {
551 pub code: String,
552 pub message: String,
553 #[serde(default, skip_serializing_if = "Vec::is_empty")]
554 pub parser_candidates: Vec<ParseCandidate>,
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
559#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
560#[cfg_attr(feature = "ts", ts(export))]
561pub struct LocalReviewBundle {
562 pub review_id: String,
563 pub generated_at: String,
564 pub pr: LocalReviewPrMeta,
565 #[serde(default)]
566 pub commits: Vec<LocalReviewCommit>,
567 #[serde(default)]
568 pub sessions: Vec<LocalReviewSession>,
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
573#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
574#[cfg_attr(feature = "ts", ts(export))]
575pub struct LocalReviewPrMeta {
576 pub url: String,
577 pub owner: String,
578 pub repo: String,
579 pub number: u64,
580 pub remote: String,
581 pub base_sha: String,
582 pub head_sha: String,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
587#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
588#[cfg_attr(feature = "ts", ts(export))]
589pub struct LocalReviewCommit {
590 pub sha: String,
591 pub title: String,
592 pub author_name: String,
593 pub author_email: String,
594 pub authored_at: String,
595 #[serde(default)]
596 pub session_ids: Vec<String>,
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
601#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
602#[cfg_attr(feature = "ts", ts(export))]
603pub struct LocalReviewSession {
604 pub session_id: String,
605 pub ledger_ref: String,
606 pub hail_path: String,
607 #[serde(default)]
608 pub commit_shas: Vec<String>,
609 #[cfg_attr(feature = "ts", ts(type = "any"))]
610 pub session: Session,
611}
612
613#[derive(Debug, Deserialize)]
617#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
618#[cfg_attr(feature = "ts", ts(export))]
619pub struct StreamEventsRequest {
620 #[cfg_attr(feature = "ts", ts(type = "any"))]
621 pub agent: Option<Agent>,
622 #[cfg_attr(feature = "ts", ts(type = "any"))]
623 pub context: Option<SessionContext>,
624 #[cfg_attr(feature = "ts", ts(type = "any[]"))]
625 pub events: Vec<Event>,
626}
627
628#[derive(Debug, Serialize, Deserialize)]
630#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
631#[cfg_attr(feature = "ts", ts(export))]
632pub struct StreamEventsResponse {
633 pub accepted: usize,
634}
635
636#[derive(Debug, Serialize, Deserialize)]
640#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
641#[cfg_attr(feature = "ts", ts(export))]
642pub struct HealthResponse {
643 pub status: String,
644 pub version: String,
645}
646
647#[derive(Debug, Serialize, Deserialize)]
649#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
650#[cfg_attr(feature = "ts", ts(export))]
651pub struct CapabilitiesResponse {
652 pub auth_enabled: bool,
653 pub parse_preview_enabled: bool,
654 pub register_targets: Vec<String>,
655 pub share_modes: Vec<String>,
656}
657
658pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
659pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
660
661impl CapabilitiesResponse {
662 pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
664 Self {
665 auth_enabled,
666 parse_preview_enabled,
667 register_targets: DEFAULT_REGISTER_TARGETS
668 .iter()
669 .map(|target| (*target).to_string())
670 .collect(),
671 share_modes: DEFAULT_SHARE_MODES
672 .iter()
673 .map(|mode| (*mode).to_string())
674 .collect(),
675 }
676 }
677}
678
679#[derive(Debug, Clone)]
686#[non_exhaustive]
687pub enum ServiceError {
688 BadRequest(String),
689 Unauthorized(String),
690 Forbidden(String),
691 NotFound(String),
692 Conflict(String),
693 Internal(String),
694}
695
696impl ServiceError {
697 pub fn status_code(&self) -> u16 {
699 match self {
700 Self::BadRequest(_) => 400,
701 Self::Unauthorized(_) => 401,
702 Self::Forbidden(_) => 403,
703 Self::NotFound(_) => 404,
704 Self::Conflict(_) => 409,
705 Self::Internal(_) => 500,
706 }
707 }
708
709 pub fn code(&self) -> &'static str {
711 match self {
712 Self::BadRequest(_) => "bad_request",
713 Self::Unauthorized(_) => "unauthorized",
714 Self::Forbidden(_) => "forbidden",
715 Self::NotFound(_) => "not_found",
716 Self::Conflict(_) => "conflict",
717 Self::Internal(_) => "internal",
718 }
719 }
720
721 pub fn message(&self) -> &str {
723 match self {
724 Self::BadRequest(m)
725 | Self::Unauthorized(m)
726 | Self::Forbidden(m)
727 | Self::NotFound(m)
728 | Self::Conflict(m)
729 | Self::Internal(m) => m,
730 }
731 }
732
733 pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
735 move |e| Self::Internal(format!("{context}: {e}"))
736 }
737}
738
739impl std::fmt::Display for ServiceError {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 write!(f, "{}", self.message())
742 }
743}
744
745impl std::error::Error for ServiceError {}
746
747#[derive(Debug, Serialize)]
751#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
752#[cfg_attr(feature = "ts", ts(export))]
753pub struct ApiError {
754 pub code: String,
755 pub message: String,
756}
757
758impl From<&ServiceError> for ApiError {
759 fn from(e: &ServiceError) -> Self {
760 Self {
761 code: e.code().to_string(),
762 message: e.message().to_string(),
763 }
764 }
765}
766
767#[cfg(test)]
770mod schema_tests {
771 use super::*;
772
773 #[test]
774 fn parse_preview_request_round_trip_git() {
775 let req = ParsePreviewRequest {
776 source: ParseSource::Git {
777 remote: "https://github.com/hwisu/opensession".to_string(),
778 r#ref: "main".to_string(),
779 path: "sessions/demo.hail.jsonl".to_string(),
780 },
781 parser_hint: Some("hail".to_string()),
782 };
783
784 let json = serde_json::to_string(&req).expect("request should serialize");
785 let decoded: ParsePreviewRequest =
786 serde_json::from_str(&json).expect("request should deserialize");
787
788 match decoded.source {
789 ParseSource::Git {
790 remote,
791 r#ref,
792 path,
793 } => {
794 assert_eq!(remote, "https://github.com/hwisu/opensession");
795 assert_eq!(r#ref, "main");
796 assert_eq!(path, "sessions/demo.hail.jsonl");
797 }
798 _ => panic!("expected git parse source"),
799 }
800 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
801 }
802
803 #[test]
804 fn parse_preview_request_round_trip_github_compat() {
805 let req = ParsePreviewRequest {
806 source: ParseSource::Github {
807 owner: "hwisu".to_string(),
808 repo: "opensession".to_string(),
809 r#ref: "main".to_string(),
810 path: "sessions/demo.hail.jsonl".to_string(),
811 },
812 parser_hint: Some("hail".to_string()),
813 };
814
815 let json = serde_json::to_string(&req).expect("request should serialize");
816 let decoded: ParsePreviewRequest =
817 serde_json::from_str(&json).expect("request should deserialize");
818
819 match decoded.source {
820 ParseSource::Github {
821 owner,
822 repo,
823 r#ref,
824 path,
825 } => {
826 assert_eq!(owner, "hwisu");
827 assert_eq!(repo, "opensession");
828 assert_eq!(r#ref, "main");
829 assert_eq!(path, "sessions/demo.hail.jsonl");
830 }
831 _ => panic!("expected github parse source"),
832 }
833 assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
834 }
835
836 #[test]
837 fn parse_preview_error_response_round_trip_with_candidates() {
838 let payload = ParsePreviewErrorResponse {
839 code: "parser_selection_required".to_string(),
840 message: "choose parser".to_string(),
841 parser_candidates: vec![ParseCandidate {
842 id: "codex".to_string(),
843 confidence: 89,
844 reason: "event markers".to_string(),
845 }],
846 };
847
848 let json = serde_json::to_string(&payload).expect("error payload should serialize");
849 let decoded: ParsePreviewErrorResponse =
850 serde_json::from_str(&json).expect("error payload should deserialize");
851
852 assert_eq!(decoded.code, "parser_selection_required");
853 assert_eq!(decoded.parser_candidates.len(), 1);
854 assert_eq!(decoded.parser_candidates[0].id, "codex");
855 }
856
857 #[test]
858 fn local_review_bundle_round_trip() {
859 let mut sample_session = Session::new(
860 "s-review-1".to_string(),
861 Agent {
862 provider: "openai".to_string(),
863 model: "gpt-5".to_string(),
864 tool: "codex".to_string(),
865 tool_version: None,
866 },
867 );
868 sample_session.recompute_stats();
869
870 let payload = LocalReviewBundle {
871 review_id: "gh-org-repo-pr1-abc1234".to_string(),
872 generated_at: "2026-02-24T00:00:00Z".to_string(),
873 pr: LocalReviewPrMeta {
874 url: "https://github.com/org/repo/pull/1".to_string(),
875 owner: "org".to_string(),
876 repo: "repo".to_string(),
877 number: 1,
878 remote: "origin".to_string(),
879 base_sha: "a".repeat(40),
880 head_sha: "b".repeat(40),
881 },
882 commits: vec![LocalReviewCommit {
883 sha: "c".repeat(40),
884 title: "feat: add review flow".to_string(),
885 author_name: "Alice".to_string(),
886 author_email: "alice@example.com".to_string(),
887 authored_at: "2026-02-24T00:00:00Z".to_string(),
888 session_ids: vec!["s-review-1".to_string()],
889 }],
890 sessions: vec![LocalReviewSession {
891 session_id: "s-review-1".to_string(),
892 ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
893 hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
894 commit_shas: vec!["c".repeat(40)],
895 session: sample_session,
896 }],
897 };
898
899 let json = serde_json::to_string(&payload).expect("review bundle should serialize");
900 let decoded: LocalReviewBundle =
901 serde_json::from_str(&json).expect("review bundle should deserialize");
902
903 assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
904 assert_eq!(decoded.pr.number, 1);
905 assert_eq!(decoded.commits.len(), 1);
906 assert_eq!(decoded.sessions.len(), 1);
907 assert_eq!(decoded.sessions[0].session_id, "s-review-1");
908 }
909
910 #[test]
911 fn capabilities_response_round_trip_includes_new_fields() {
912 let caps = CapabilitiesResponse::for_runtime(true, true);
913
914 let json = serde_json::to_string(&caps).expect("capabilities should serialize");
915 let decoded: CapabilitiesResponse =
916 serde_json::from_str(&json).expect("capabilities should deserialize");
917
918 assert!(decoded.auth_enabled);
919 assert!(decoded.parse_preview_enabled);
920 assert_eq!(decoded.register_targets, vec!["local", "git"]);
921 assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
922 }
923
924 #[test]
925 fn capabilities_defaults_are_stable() {
926 assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
927 assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
928 }
929}
930
931#[cfg(all(test, feature = "ts"))]
932mod tests {
933 use super::*;
934 use std::io::Write;
935 use std::path::PathBuf;
936 use ts_rs::TS;
937
938 #[test]
940 fn export_typescript() {
941 let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
942 .join("../../packages/ui/src/api-types.generated.ts");
943
944 let cfg = ts_rs::Config::new().with_large_int("number");
945 let mut parts: Vec<String> = Vec::new();
946 parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
947 parts.push(
948 "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
949 );
950 parts.push(String::new());
951
952 macro_rules! collect_ts {
956 ($($t:ty),+ $(,)?) => {
957 $(
958 let decl = <$t>::decl(&cfg);
959 let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
960 let decl = if is_struct_decl {
961 decl
963 .replacen("type ", "export interface ", 1)
964 .replace(" = {", " {")
965 .trim_end_matches(';')
966 .to_string()
967 } else {
968 decl
970 .replacen("type ", "export type ", 1)
971 .trim_end_matches(';')
972 .to_string()
973 };
974 parts.push(decl);
975 parts.push(String::new());
976 )+
977 };
978 }
979
980 collect_ts!(
981 SortOrder,
983 TimeRange,
984 LinkType,
985 AuthRegisterRequest,
987 LoginRequest,
988 AuthTokenResponse,
989 RefreshRequest,
990 LogoutRequest,
991 ChangePasswordRequest,
992 VerifyResponse,
993 UserSettingsResponse,
994 OkResponse,
995 IssueApiKeyResponse,
996 GitCredentialSummary,
997 ListGitCredentialsResponse,
998 CreateGitCredentialRequest,
999 OAuthLinkResponse,
1000 UploadResponse,
1002 SessionSummary,
1003 SessionListResponse,
1004 SessionListQuery,
1005 SessionDetail,
1006 SessionLink,
1007 ParseSource,
1008 ParseCandidate,
1009 ParsePreviewRequest,
1010 ParsePreviewResponse,
1011 ParsePreviewErrorResponse,
1012 LocalReviewBundle,
1013 LocalReviewPrMeta,
1014 LocalReviewCommit,
1015 LocalReviewSession,
1016 oauth::AuthProvidersResponse,
1018 oauth::OAuthProviderInfo,
1019 oauth::LinkedProvider,
1020 HealthResponse,
1022 CapabilitiesResponse,
1023 ApiError,
1024 );
1025
1026 let content = parts.join("\n");
1027
1028 if let Some(parent) = out_dir.parent() {
1030 std::fs::create_dir_all(parent).ok();
1031 }
1032 let mut file = std::fs::File::create(&out_dir)
1033 .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1034 file.write_all(content.as_bytes())
1035 .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1036
1037 println!("Generated TypeScript types at: {}", out_dir.display());
1038 }
1039}