1use anyhow::Result;
2use chrono::DateTime;
3use chrono::Utc;
4use codex_protocol::ThreadId;
5use codex_protocol::openai_models::ReasoningEffort;
6use codex_protocol::protocol::AskForApproval;
7use codex_protocol::protocol::SandboxPolicy;
8use codex_protocol::protocol::SessionSource;
9use codex_protocol::protocol::ThreadHistoryMode;
10use codex_protocol::protocol::ThreadSource;
11use sqlx::Row;
12use sqlx::sqlite::SqliteRow;
13use std::collections::HashMap;
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SortKey {
19 CreatedAt,
21 UpdatedAt,
23 RecencyAt,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SortDirection {
30 Asc,
31 Desc,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ThreadRelationFilter {
37 DirectChildrenOf(ThreadId),
39 DescendantsOf(ThreadId),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Anchor {
46 pub ts: DateTime<Utc>,
48 pub id: Option<ThreadId>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ThreadsPage {
55 pub items: Vec<ThreadMetadata>,
57 pub parent_thread_ids: HashMap<ThreadId, ThreadId>,
59 pub next_anchor: Option<Anchor>,
61 pub num_scanned_rows: usize,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct ExtractionOutcome {
68 pub metadata: ThreadMetadata,
70 pub memory_mode: Option<String>,
72 pub parse_errors: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct ThreadMetadata {
79 pub id: ThreadId,
81 pub rollout_path: PathBuf,
83 pub created_at: DateTime<Utc>,
85 pub updated_at: DateTime<Utc>,
87 pub recency_at: DateTime<Utc>,
89 pub source: String,
91 pub history_mode: ThreadHistoryMode,
93 pub thread_source: Option<ThreadSource>,
95 pub agent_nickname: Option<String>,
97 pub agent_role: Option<String>,
99 pub agent_path: Option<String>,
101 pub model_provider: String,
103 pub model: Option<String>,
105 pub reasoning_effort: Option<ReasoningEffort>,
107 pub cwd: PathBuf,
109 pub cli_version: String,
111 pub title: String,
113 pub name: Option<String>,
115 pub preview: Option<String>,
117 pub sandbox_policy: String,
119 pub approval_mode: String,
121 pub tokens_used: i64,
123 pub first_user_message: Option<String>,
125 pub archived_at: Option<DateTime<Utc>>,
127 pub git_sha: Option<String>,
129 pub git_branch: Option<String>,
131 pub git_origin_url: Option<String>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct ThreadMetadataBuilder {
138 pub id: ThreadId,
140 pub rollout_path: PathBuf,
142 pub created_at: DateTime<Utc>,
144 pub updated_at: Option<DateTime<Utc>>,
146 pub recency_at: Option<DateTime<Utc>>,
148 pub source: SessionSource,
150 pub history_mode: ThreadHistoryMode,
152 pub thread_source: Option<ThreadSource>,
154 pub agent_nickname: Option<String>,
156 pub agent_role: Option<String>,
158 pub agent_path: Option<String>,
160 pub model_provider: Option<String>,
162 pub cwd: PathBuf,
164 pub cli_version: Option<String>,
166 pub sandbox_policy: SandboxPolicy,
168 pub approval_mode: AskForApproval,
170 pub archived_at: Option<DateTime<Utc>>,
172 pub git_sha: Option<String>,
174 pub git_branch: Option<String>,
176 pub git_origin_url: Option<String>,
178}
179
180impl ThreadMetadataBuilder {
181 pub fn new(
183 id: ThreadId,
184 rollout_path: PathBuf,
185 created_at: DateTime<Utc>,
186 source: SessionSource,
187 ) -> Self {
188 Self {
189 id,
190 rollout_path,
191 created_at,
192 updated_at: None,
193 recency_at: None,
194 source,
195 history_mode: ThreadHistoryMode::Legacy,
196 thread_source: None,
197 agent_nickname: None,
198 agent_role: None,
199 agent_path: None,
200 model_provider: None,
201 cwd: PathBuf::new(),
202 cli_version: None,
203 sandbox_policy: SandboxPolicy::new_read_only_policy(),
204 approval_mode: AskForApproval::OnRequest,
205 archived_at: None,
206 git_sha: None,
207 git_branch: None,
208 git_origin_url: None,
209 }
210 }
211
212 pub fn build(&self, default_provider: &str) -> ThreadMetadata {
214 let source = crate::extract::enum_to_string(&self.source);
215 let sandbox_policy = crate::extract::enum_to_string(&self.sandbox_policy);
216 let approval_mode = crate::extract::enum_to_string(&self.approval_mode);
217 let created_at = canonicalize_datetime(self.created_at);
218 let updated_at = self
219 .updated_at
220 .map(canonicalize_datetime)
221 .unwrap_or(created_at);
222 let recency_at = self
223 .recency_at
224 .map(canonicalize_datetime)
225 .unwrap_or(updated_at);
226 ThreadMetadata {
227 id: self.id,
228 rollout_path: self.rollout_path.clone(),
229 created_at,
230 updated_at,
231 recency_at,
232 source,
233 history_mode: self.history_mode,
234 thread_source: self.thread_source.clone(),
235 agent_nickname: self.agent_nickname.clone(),
236 agent_role: self.agent_role.clone(),
237 agent_path: self
238 .agent_path
239 .clone()
240 .or_else(|| self.source.get_agent_path().map(Into::into)),
241 model_provider: self
242 .model_provider
243 .clone()
244 .unwrap_or_else(|| default_provider.to_string()),
245 model: None,
246 reasoning_effort: None,
247 cwd: self.cwd.clone(),
248 cli_version: self.cli_version.clone().unwrap_or_default(),
249 title: String::new(),
250 name: None,
251 preview: None,
252 sandbox_policy,
253 approval_mode,
254 tokens_used: 0,
255 first_user_message: None,
256 archived_at: self.archived_at.map(canonicalize_datetime),
257 git_sha: self.git_sha.clone(),
258 git_branch: self.git_branch.clone(),
259 git_origin_url: self.git_origin_url.clone(),
260 }
261 }
262}
263
264impl ThreadMetadata {
265 pub fn prefer_existing_git_info(&mut self, existing: &Self) {
267 if matches!(self.history_mode, ThreadHistoryMode::Paginated)
268 && matches!(existing.history_mode, ThreadHistoryMode::Paginated)
269 {
270 self.git_sha = existing.git_sha.clone();
275 self.git_branch = existing.git_branch.clone();
276 self.git_origin_url = existing.git_origin_url.clone();
277 return;
278 }
279 if existing.git_sha.is_some() {
280 self.git_sha = existing.git_sha.clone();
281 }
282 if existing.git_branch.is_some() {
283 self.git_branch = existing.git_branch.clone();
284 }
285 if existing.git_origin_url.is_some() {
286 self.git_origin_url = existing.git_origin_url.clone();
287 }
288 }
289
290 pub fn prefer_existing_explicit_title(&mut self, existing: &Self) {
292 let existing_title = existing.title.trim();
293 if existing_title.is_empty()
294 || existing.first_user_message.as_deref().map(str::trim) == Some(existing_title)
295 {
296 return;
297 }
298
299 let title = self.title.trim();
300 if title.is_empty() || self.first_user_message.as_deref().map(str::trim) == Some(title) {
301 self.title = existing.title.clone();
302 }
303 }
304
305 pub fn diff_fields(&self, other: &Self) -> Vec<&'static str> {
307 let mut diffs = Vec::new();
308 if self.id != other.id {
309 diffs.push("id");
310 }
311 if self.rollout_path != other.rollout_path {
312 diffs.push("rollout_path");
313 }
314 if self.created_at != other.created_at {
315 diffs.push("created_at");
316 }
317 if self.updated_at != other.updated_at {
318 diffs.push("updated_at");
319 }
320 if self.source != other.source {
321 diffs.push("source");
322 }
323 if self.agent_nickname != other.agent_nickname {
324 diffs.push("agent_nickname");
325 }
326 if self.agent_role != other.agent_role {
327 diffs.push("agent_role");
328 }
329 if self.agent_path != other.agent_path {
330 diffs.push("agent_path");
331 }
332 if self.model_provider != other.model_provider {
333 diffs.push("model_provider");
334 }
335 if self.model != other.model {
336 diffs.push("model");
337 }
338 if self.reasoning_effort != other.reasoning_effort {
339 diffs.push("reasoning_effort");
340 }
341 if self.cwd != other.cwd {
342 diffs.push("cwd");
343 }
344 if self.cli_version != other.cli_version {
345 diffs.push("cli_version");
346 }
347 if self.title != other.title {
348 diffs.push("title");
349 }
350 if self.name != other.name {
351 diffs.push("name");
352 }
353 if self.preview != other.preview {
354 diffs.push("preview");
355 }
356 if self.sandbox_policy != other.sandbox_policy {
357 diffs.push("sandbox_policy");
358 }
359 if self.approval_mode != other.approval_mode {
360 diffs.push("approval_mode");
361 }
362 if self.tokens_used != other.tokens_used {
363 diffs.push("tokens_used");
364 }
365 if self.first_user_message != other.first_user_message {
366 diffs.push("first_user_message");
367 }
368 if self.archived_at != other.archived_at {
369 diffs.push("archived_at");
370 }
371 if self.git_sha != other.git_sha {
372 diffs.push("git_sha");
373 }
374 if self.git_branch != other.git_branch {
375 diffs.push("git_branch");
376 }
377 if self.git_origin_url != other.git_origin_url {
378 diffs.push("git_origin_url");
379 }
380 diffs
381 }
382}
383
384fn canonicalize_datetime(dt: DateTime<Utc>) -> DateTime<Utc> {
385 epoch_millis_to_datetime(datetime_to_epoch_millis(dt)).unwrap_or(dt)
386}
387
388#[derive(Debug)]
389pub(crate) struct ThreadRow {
390 id: String,
391 rollout_path: String,
392 created_at: i64,
393 updated_at: i64,
394 recency_at: i64,
395 source: String,
396 history_mode: String,
397 thread_source: Option<String>,
398 agent_nickname: Option<String>,
399 agent_role: Option<String>,
400 agent_path: Option<String>,
401 model_provider: String,
402 model: Option<String>,
403 reasoning_effort: Option<String>,
404 cwd: String,
405 cli_version: String,
406 title: String,
407 name: Option<String>,
408 preview: String,
409 sandbox_policy: String,
410 approval_mode: String,
411 tokens_used: i64,
412 first_user_message: String,
413 archived_at: Option<i64>,
414 git_sha: Option<String>,
415 git_branch: Option<String>,
416 git_origin_url: Option<String>,
417}
418
419impl ThreadRow {
420 pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
421 Ok(Self {
422 id: row.try_get("id")?,
423 rollout_path: row.try_get("rollout_path")?,
424 created_at: row.try_get("created_at")?,
425 updated_at: row.try_get("updated_at")?,
426 recency_at: row.try_get("recency_at")?,
427 source: row.try_get("source")?,
428 history_mode: row.try_get("history_mode")?,
429 thread_source: row.try_get("thread_source")?,
430 agent_nickname: row.try_get("agent_nickname")?,
431 agent_role: row.try_get("agent_role")?,
432 agent_path: row.try_get("agent_path")?,
433 model_provider: row.try_get("model_provider")?,
434 model: row.try_get("model")?,
435 reasoning_effort: row.try_get("reasoning_effort")?,
436 cwd: row.try_get("cwd")?,
437 cli_version: row.try_get("cli_version")?,
438 title: row.try_get("title")?,
439 name: row.try_get("name")?,
440 preview: row.try_get("preview")?,
441 sandbox_policy: row.try_get("sandbox_policy")?,
442 approval_mode: row.try_get("approval_mode")?,
443 tokens_used: row.try_get("tokens_used")?,
444 first_user_message: row.try_get("first_user_message")?,
445 archived_at: row.try_get("archived_at")?,
446 git_sha: row.try_get("git_sha")?,
447 git_branch: row.try_get("git_branch")?,
448 git_origin_url: row.try_get("git_origin_url")?,
449 })
450 }
451}
452
453impl TryFrom<ThreadRow> for ThreadMetadata {
454 type Error = anyhow::Error;
455
456 fn try_from(row: ThreadRow) -> std::result::Result<Self, Self::Error> {
457 let ThreadRow {
458 id,
459 rollout_path,
460 created_at,
461 updated_at,
462 recency_at,
463 source,
464 history_mode,
465 thread_source,
466 agent_nickname,
467 agent_role,
468 agent_path,
469 model_provider,
470 model,
471 reasoning_effort,
472 cwd,
473 cli_version,
474 title,
475 name,
476 preview,
477 sandbox_policy,
478 approval_mode,
479 tokens_used,
480 first_user_message,
481 archived_at,
482 git_sha,
483 git_branch,
484 git_origin_url,
485 } = row;
486 let thread_source = thread_source
487 .map(|thread_source| thread_source.parse())
488 .transpose()
489 .map_err(anyhow::Error::msg)?;
490 let history_mode = history_mode.parse().map_err(anyhow::Error::msg)?;
491 Ok(Self {
492 id: ThreadId::try_from(id)?,
493 rollout_path: PathBuf::from(rollout_path),
494 created_at: epoch_millis_to_datetime(created_at)?,
495 updated_at: epoch_millis_to_datetime(updated_at)?,
496 recency_at: epoch_millis_to_datetime(recency_at)?,
497 source,
498 history_mode,
499 thread_source,
500 agent_nickname,
501 agent_role,
502 agent_path,
503 model_provider,
504 model,
505 reasoning_effort: reasoning_effort
506 .and_then(|value| value.parse::<ReasoningEffort>().ok()),
507 cwd: PathBuf::from(cwd),
508 cli_version,
509 title,
510 name,
511 preview: (!preview.is_empty()).then_some(preview),
512 sandbox_policy,
513 approval_mode,
514 tokens_used,
515 first_user_message: (!first_user_message.is_empty()).then_some(first_user_message),
516 archived_at: archived_at.map(epoch_seconds_to_datetime).transpose()?,
517 git_sha,
518 git_branch,
519 git_origin_url,
520 })
521 }
522}
523
524pub(crate) fn anchor_from_item(
525 item: &ThreadMetadata,
526 sort_key: SortKey,
527 include_thread_id_tiebreaker: bool,
528) -> Option<Anchor> {
529 let ts = match sort_key {
530 SortKey::CreatedAt => item.created_at,
531 SortKey::UpdatedAt => item.updated_at,
532 SortKey::RecencyAt => item.recency_at,
533 };
534 Some(Anchor {
535 ts,
536 id: (include_thread_id_tiebreaker || sort_key == SortKey::RecencyAt).then_some(item.id),
537 })
538}
539
540pub(crate) fn datetime_to_epoch_millis(dt: DateTime<Utc>) -> i64 {
541 dt.timestamp_millis()
542}
543
544pub(crate) fn datetime_to_epoch_seconds(dt: DateTime<Utc>) -> i64 {
545 dt.timestamp()
546}
547
548pub(crate) fn epoch_millis_to_datetime(value: i64) -> Result<DateTime<Utc>> {
549 const MIN_EPOCH_MILLIS: i64 = 1_577_836_800_000;
552 let millis = if value < MIN_EPOCH_MILLIS {
553 value.saturating_mul(1000)
554 } else {
555 value
556 };
557 DateTime::<Utc>::from_timestamp_millis(millis)
558 .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp millis: {value}"))
559}
560
561pub(crate) fn epoch_seconds_to_datetime(value: i64) -> Result<DateTime<Utc>> {
562 DateTime::<Utc>::from_timestamp(value, 0)
563 .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp seconds: {value}"))
564}
565
566#[derive(Debug, Clone)]
568pub struct BackfillStats {
569 pub scanned: usize,
571 pub upserted: usize,
573 pub failed: usize,
575}
576
577#[cfg(test)]
578mod tests {
579 use super::ThreadMetadata;
580 use super::ThreadRow;
581 use chrono::DateTime;
582 use chrono::Utc;
583 use codex_protocol::ThreadId;
584 use codex_protocol::openai_models::ReasoningEffort;
585 use codex_protocol::protocol::ThreadHistoryMode;
586 use pretty_assertions::assert_eq;
587 use std::path::PathBuf;
588
589 fn thread_row(reasoning_effort: Option<&str>) -> ThreadRow {
590 ThreadRow {
591 id: "00000000-0000-0000-0000-000000000123".to_string(),
592 rollout_path: "/tmp/rollout-123.jsonl".to_string(),
593 created_at: 1_700_000_000,
594 updated_at: 1_700_000_100,
595 recency_at: 1_700_000_100,
596 source: "cli".to_string(),
597 history_mode: "legacy".to_string(),
598 thread_source: None,
599 agent_nickname: None,
600 agent_role: None,
601 agent_path: None,
602 model_provider: "openai".to_string(),
603 model: Some("gpt-5".to_string()),
604 reasoning_effort: reasoning_effort.map(str::to_string),
605 cwd: "/tmp/workspace".to_string(),
606 cli_version: "0.0.0".to_string(),
607 title: String::new(),
608 name: None,
609 preview: String::new(),
610 sandbox_policy: "read-only".to_string(),
611 approval_mode: "on-request".to_string(),
612 tokens_used: 1,
613 first_user_message: String::new(),
614 archived_at: None,
615 git_sha: None,
616 git_branch: None,
617 git_origin_url: None,
618 }
619 }
620
621 fn expected_thread_metadata(reasoning_effort: Option<ReasoningEffort>) -> ThreadMetadata {
622 ThreadMetadata {
623 id: ThreadId::from_string("00000000-0000-0000-0000-000000000123")
624 .expect("valid thread id"),
625 rollout_path: PathBuf::from("/tmp/rollout-123.jsonl"),
626 created_at: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp"),
627 updated_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
628 recency_at: DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
629 source: "cli".to_string(),
630 history_mode: ThreadHistoryMode::Legacy,
631 thread_source: None,
632 agent_nickname: None,
633 agent_role: None,
634 agent_path: None,
635 model_provider: "openai".to_string(),
636 model: Some("gpt-5".to_string()),
637 reasoning_effort,
638 cwd: PathBuf::from("/tmp/workspace"),
639 cli_version: "0.0.0".to_string(),
640 title: String::new(),
641 name: None,
642 preview: None,
643 sandbox_policy: "read-only".to_string(),
644 approval_mode: "on-request".to_string(),
645 tokens_used: 1,
646 first_user_message: None,
647 archived_at: None,
648 git_sha: None,
649 git_branch: None,
650 git_origin_url: None,
651 }
652 }
653
654 #[test]
655 fn thread_row_parses_reasoning_effort() {
656 let metadata = ThreadMetadata::try_from(thread_row(Some("high")))
657 .expect("thread metadata should parse");
658
659 assert_eq!(
660 metadata,
661 expected_thread_metadata(Some(ReasoningEffort::High))
662 );
663 }
664
665 #[test]
666 fn thread_row_preserves_model_defined_reasoning_effort_values() {
667 let metadata = ThreadMetadata::try_from(thread_row(Some("future")))
668 .expect("thread metadata should parse");
669
670 assert_eq!(
671 metadata,
672 expected_thread_metadata(Some(ReasoningEffort::Custom("future".to_string())))
673 );
674 }
675
676 #[test]
677 fn thread_row_rejects_unknown_history_mode() {
678 let mut row = thread_row(None);
679 row.history_mode = "future".to_string();
680
681 assert!(ThreadMetadata::try_from(row).is_err());
682 }
683
684 #[test]
685 fn paginated_rollout_git_info_keeps_rollout_values_until_sqlite_mode_is_paginated() {
686 let mut reconciled = expected_thread_metadata(None);
687 reconciled.history_mode = ThreadHistoryMode::Paginated;
688 reconciled.git_sha = Some("rollout-sha".to_string());
689 reconciled.git_branch = Some("rollout-branch".to_string());
690 reconciled.git_origin_url = Some("rollout-origin".to_string());
691 let existing = expected_thread_metadata(None);
692 let expected = reconciled.clone();
693
694 reconciled.prefer_existing_git_info(&existing);
695
696 assert_eq!(reconciled, expected);
697 }
698}