Skip to main content

codex_state/runtime/
threads.rs

1use super::*;
2use crate::SortDirection;
3use codex_protocol::protocol::SessionSource;
4use std::sync::atomic::AtomicI64;
5use std::sync::atomic::Ordering;
6
7impl StateRuntime {
8    pub async fn get_thread(&self, id: ThreadId) -> anyhow::Result<Option<crate::ThreadMetadata>> {
9        let row = sqlx::query(
10            r#"
11SELECT
12    threads.id,
13    threads.rollout_path,
14    threads.created_at_ms AS created_at,
15    threads.updated_at_ms AS updated_at,
16    threads.recency_at_ms AS recency_at,
17    threads.source,
18    threads.history_mode,
19    threads.thread_source,
20    threads.agent_nickname,
21    threads.agent_role,
22    threads.agent_path,
23    threads.model_provider,
24    threads.model,
25    threads.reasoning_effort,
26    threads.cwd,
27    threads.cli_version,
28    threads.title,
29    threads.name,
30    threads.preview,
31    threads.sandbox_policy,
32    threads.approval_mode,
33    threads.tokens_used,
34    threads.first_user_message,
35    threads.archived_at,
36    threads.git_sha,
37    threads.git_branch,
38    threads.git_origin_url
39FROM threads
40WHERE threads.id = ?
41            "#,
42        )
43        .bind(id.to_string())
44        .fetch_optional(self.pool.as_ref())
45        .await?;
46        row.map(|row| ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from))
47            .transpose()
48    }
49
50    pub async fn get_thread_memory_mode(&self, id: ThreadId) -> anyhow::Result<Option<String>> {
51        let row = sqlx::query("SELECT memory_mode FROM threads WHERE id = ?")
52            .bind(id.to_string())
53            .fetch_optional(self.pool.as_ref())
54            .await?;
55        Ok(row.and_then(|row| row.try_get("memory_mode").ok()))
56    }
57
58    pub async fn set_thread_preview_if_empty(
59        &self,
60        thread_id: ThreadId,
61        preview: &str,
62    ) -> anyhow::Result<bool> {
63        let preview = preview.trim();
64        if preview.is_empty() {
65            return Ok(false);
66        }
67        let result = sqlx::query(
68            r#"
69UPDATE threads
70SET preview = ?
71WHERE id = ? AND preview = ''
72            "#,
73        )
74        .bind(preview)
75        .bind(thread_id.to_string())
76        .execute(self.pool.as_ref())
77        .await?;
78        Ok(result.rows_affected() > 0)
79    }
80
81    /// Persist or replace the directional parent-child edge for a spawned thread.
82    pub async fn upsert_thread_spawn_edge(
83        &self,
84        parent_thread_id: ThreadId,
85        child_thread_id: ThreadId,
86        status: crate::DirectionalThreadSpawnEdgeStatus,
87    ) -> anyhow::Result<()> {
88        sqlx::query(
89            r#"
90INSERT INTO thread_spawn_edges (
91    parent_thread_id,
92    child_thread_id,
93    status
94) VALUES (?, ?, ?)
95ON CONFLICT(child_thread_id) DO UPDATE SET
96    parent_thread_id = excluded.parent_thread_id,
97    status = excluded.status
98            "#,
99        )
100        .bind(parent_thread_id.to_string())
101        .bind(child_thread_id.to_string())
102        .bind(status.as_ref())
103        .execute(self.pool.as_ref())
104        .await?;
105        Ok(())
106    }
107
108    /// Update the persisted lifecycle status of a spawned thread's incoming edge.
109    pub async fn set_thread_spawn_edge_status(
110        &self,
111        child_thread_id: ThreadId,
112        status: crate::DirectionalThreadSpawnEdgeStatus,
113    ) -> anyhow::Result<()> {
114        sqlx::query("UPDATE thread_spawn_edges SET status = ? WHERE child_thread_id = ?")
115            .bind(status.as_ref())
116            .bind(child_thread_id.to_string())
117            .execute(self.pool.as_ref())
118            .await?;
119        Ok(())
120    }
121
122    /// List direct spawned children of `parent_thread_id` whose edge matches `status`.
123    pub async fn list_thread_spawn_children_with_status(
124        &self,
125        parent_thread_id: ThreadId,
126        status: crate::DirectionalThreadSpawnEdgeStatus,
127    ) -> anyhow::Result<Vec<ThreadId>> {
128        self.list_thread_spawn_children_matching(parent_thread_id, Some(status))
129            .await
130    }
131
132    /// List all direct spawned children of `parent_thread_id`.
133    pub async fn list_thread_spawn_children(
134        &self,
135        parent_thread_id: ThreadId,
136    ) -> anyhow::Result<Vec<ThreadId>> {
137        self.list_thread_spawn_children_matching(parent_thread_id, /*status*/ None)
138            .await
139    }
140
141    /// List spawned descendants of `root_thread_id` whose edges match `status`.
142    ///
143    /// Descendants are returned breadth-first by depth, then by thread id for stable ordering.
144    pub async fn list_thread_spawn_descendants_with_status(
145        &self,
146        root_thread_id: ThreadId,
147        status: crate::DirectionalThreadSpawnEdgeStatus,
148    ) -> anyhow::Result<Vec<ThreadId>> {
149        self.list_thread_spawn_descendants_matching(root_thread_id, Some(status))
150            .await
151    }
152
153    /// List all spawned descendants of `root_thread_id`.
154    ///
155    /// Descendants are returned breadth-first by depth, then by thread id for stable ordering.
156    pub async fn list_thread_spawn_descendants(
157        &self,
158        root_thread_id: ThreadId,
159    ) -> anyhow::Result<Vec<ThreadId>> {
160        self.list_thread_spawn_descendants_matching(root_thread_id, /*status*/ None)
161            .await
162    }
163
164    /// Find a direct spawned child of `parent_thread_id` by canonical agent path.
165    pub async fn find_thread_spawn_child_by_path(
166        &self,
167        parent_thread_id: ThreadId,
168        agent_path: &str,
169    ) -> anyhow::Result<Option<ThreadId>> {
170        let rows = sqlx::query(
171            r#"
172SELECT threads.id
173FROM thread_spawn_edges
174JOIN threads ON threads.id = thread_spawn_edges.child_thread_id
175WHERE thread_spawn_edges.parent_thread_id = ?
176  AND threads.agent_path = ?
177ORDER BY threads.id
178LIMIT 2
179            "#,
180        )
181        .bind(parent_thread_id.to_string())
182        .bind(agent_path)
183        .fetch_all(self.pool.as_ref())
184        .await?;
185        one_thread_id_from_rows(rows, agent_path)
186    }
187
188    /// Find a spawned descendant of `root_thread_id` by canonical agent path.
189    pub async fn find_thread_spawn_descendant_by_path(
190        &self,
191        root_thread_id: ThreadId,
192        agent_path: &str,
193    ) -> anyhow::Result<Option<ThreadId>> {
194        let rows = sqlx::query(
195            r#"
196WITH RECURSIVE subtree(child_thread_id) AS (
197    SELECT child_thread_id
198    FROM thread_spawn_edges
199    WHERE parent_thread_id = ?
200    UNION ALL
201    SELECT edge.child_thread_id
202    FROM thread_spawn_edges AS edge
203    JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
204)
205SELECT threads.id
206FROM subtree
207JOIN threads ON threads.id = subtree.child_thread_id
208WHERE threads.agent_path = ?
209ORDER BY threads.id
210LIMIT 2
211            "#,
212        )
213        .bind(root_thread_id.to_string())
214        .bind(agent_path)
215        .fetch_all(self.pool.as_ref())
216        .await?;
217        one_thread_id_from_rows(rows, agent_path)
218    }
219
220    async fn list_thread_spawn_children_matching(
221        &self,
222        parent_thread_id: ThreadId,
223        status: Option<crate::DirectionalThreadSpawnEdgeStatus>,
224    ) -> anyhow::Result<Vec<ThreadId>> {
225        let mut builder = QueryBuilder::<Sqlite>::new(
226            "SELECT child_thread_id FROM thread_spawn_edges WHERE parent_thread_id = ",
227        );
228        builder.push_bind(parent_thread_id.to_string());
229        if let Some(status) = status {
230            builder.push(" AND status = ").push_bind(status.to_string());
231        }
232        builder.push(" ORDER BY child_thread_id");
233
234        let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
235        rows.into_iter()
236            .map(|row| {
237                ThreadId::try_from(row.try_get::<String, _>("child_thread_id")?).map_err(Into::into)
238            })
239            .collect()
240    }
241
242    async fn list_thread_spawn_descendants_matching(
243        &self,
244        root_thread_id: ThreadId,
245        status: Option<crate::DirectionalThreadSpawnEdgeStatus>,
246    ) -> anyhow::Result<Vec<ThreadId>> {
247        let mut builder = QueryBuilder::<Sqlite>::new(
248            r#"
249WITH RECURSIVE subtree(child_thread_id, depth) AS (
250    SELECT child_thread_id, 1
251    FROM thread_spawn_edges
252    WHERE parent_thread_id =
253            "#,
254        );
255        builder.push_bind(root_thread_id.to_string());
256        if let Some(status) = status {
257            let status = status.to_string();
258            builder.push(" AND status = ").push_bind(status.clone());
259            builder.push(
260                r#"
261    UNION ALL
262    SELECT edge.child_thread_id, subtree.depth + 1
263    FROM thread_spawn_edges AS edge
264    JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
265    WHERE status =
266                "#,
267            );
268            builder.push_bind(status);
269        } else {
270            builder.push(
271                r#"
272    UNION ALL
273    SELECT edge.child_thread_id, subtree.depth + 1
274    FROM thread_spawn_edges AS edge
275    JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
276                "#,
277            );
278        }
279        builder.push(
280            r#"
281)
282SELECT child_thread_id
283FROM subtree
284ORDER BY depth ASC, child_thread_id ASC
285            "#,
286        );
287
288        let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
289        rows.into_iter()
290            .map(|row| {
291                ThreadId::try_from(row.try_get::<String, _>("child_thread_id")?).map_err(Into::into)
292            })
293            .collect()
294    }
295
296    async fn insert_thread_spawn_edge_if_absent(
297        &self,
298        parent_thread_id: ThreadId,
299        child_thread_id: ThreadId,
300    ) -> anyhow::Result<()> {
301        sqlx::query(
302            r#"
303INSERT INTO thread_spawn_edges (
304    parent_thread_id,
305    child_thread_id,
306    status
307) VALUES (?, ?, ?)
308ON CONFLICT(child_thread_id) DO NOTHING
309            "#,
310        )
311        .bind(parent_thread_id.to_string())
312        .bind(child_thread_id.to_string())
313        .bind(crate::DirectionalThreadSpawnEdgeStatus::Open.as_ref())
314        .execute(self.pool.as_ref())
315        .await?;
316        Ok(())
317    }
318
319    async fn insert_thread_spawn_edge_from_source_if_absent(
320        &self,
321        child_thread_id: ThreadId,
322        source: &str,
323    ) -> anyhow::Result<()> {
324        let Some(parent_thread_id) = thread_spawn_parent_thread_id_from_source_str(source) else {
325            return Ok(());
326        };
327        self.insert_thread_spawn_edge_if_absent(parent_thread_id, child_thread_id)
328            .await
329    }
330
331    /// Find a rollout path by thread id using the underlying database.
332    pub async fn find_rollout_path_by_id(
333        &self,
334        id: ThreadId,
335        archived_only: Option<bool>,
336    ) -> anyhow::Result<Option<PathBuf>> {
337        let mut builder =
338            QueryBuilder::<Sqlite>::new("SELECT rollout_path FROM threads WHERE id = ");
339        builder.push_bind(id.to_string());
340        match archived_only {
341            Some(true) => {
342                builder.push(" AND archived = 1");
343            }
344            Some(false) => {
345                builder.push(" AND archived = 0");
346            }
347            None => {}
348        }
349        let row = builder.build().fetch_optional(self.pool.as_ref()).await?;
350        Ok(row
351            .and_then(|r| r.try_get::<String, _>("rollout_path").ok())
352            .map(PathBuf::from))
353    }
354
355    /// Find the newest thread whose user-facing title exactly matches `title`.
356    #[allow(clippy::too_many_arguments)]
357    pub async fn find_thread_by_exact_title(
358        &self,
359        title: &str,
360        allowed_sources: &[String],
361        model_providers: Option<&[String]>,
362        archived_only: bool,
363        cwd: Option<&Path>,
364    ) -> anyhow::Result<Option<crate::ThreadMetadata>> {
365        let mut builder = QueryBuilder::<Sqlite>::new("");
366        push_thread_select_columns(&mut builder);
367        builder.push(" FROM threads");
368        push_thread_filters(
369            &mut builder,
370            ThreadFilterOptions {
371                archived_only,
372                allowed_sources,
373                model_providers,
374                cwd_filters: None,
375                anchor: None,
376                sort_key: crate::SortKey::UpdatedAt,
377                sort_direction: SortDirection::Desc,
378                search_term: None,
379            },
380            /*include_thread_id_tiebreaker*/ false,
381        );
382        builder.push(" AND threads.title = ");
383        builder.push_bind(title);
384        if let Some(cwd) = cwd {
385            builder.push(" AND threads.cwd = ");
386            builder.push_bind(cwd.display().to_string());
387        }
388        push_thread_order_and_limit(
389            &mut builder,
390            crate::SortKey::UpdatedAt,
391            SortDirection::Desc,
392            OrderByIndex::Enabled,
393            /*include_thread_id_tiebreaker*/ false,
394            /*limit*/ 1,
395        );
396
397        let row = builder.build().fetch_optional(self.pool.as_ref()).await?;
398        row.map(|row| ThreadRow::try_from_row(&row).and_then(crate::ThreadMetadata::try_from))
399            .transpose()
400    }
401
402    /// List threads using the underlying database.
403    pub async fn list_threads(
404        &self,
405        page_size: usize,
406        filters: ThreadFilterOptions<'_>,
407    ) -> anyhow::Result<crate::ThreadsPage> {
408        self.list_threads_matching(page_size, filters, /*relation_filter*/ None)
409            .await
410    }
411
412    /// List direct children of `parent_thread_id` using persisted spawn edges.
413    pub async fn list_threads_by_parent(
414        &self,
415        page_size: usize,
416        parent_thread_id: ThreadId,
417        filters: ThreadFilterOptions<'_>,
418    ) -> anyhow::Result<crate::ThreadsPage> {
419        self.list_threads_by_relation(
420            page_size,
421            crate::ThreadRelationFilter::DirectChildrenOf(parent_thread_id),
422            filters,
423        )
424        .await
425    }
426
427    /// List threads matching a persisted spawn-graph relationship.
428    pub async fn list_threads_by_relation(
429        &self,
430        page_size: usize,
431        relation_filter: crate::ThreadRelationFilter,
432        filters: ThreadFilterOptions<'_>,
433    ) -> anyhow::Result<crate::ThreadsPage> {
434        self.list_threads_matching(page_size, filters, Some(relation_filter))
435            .await
436    }
437
438    async fn list_threads_matching(
439        &self,
440        page_size: usize,
441        filters: ThreadFilterOptions<'_>,
442        relation_filter: Option<crate::ThreadRelationFilter>,
443    ) -> anyhow::Result<crate::ThreadsPage> {
444        let limit = page_size.saturating_add(1);
445
446        let mut builder = QueryBuilder::<Sqlite>::new("");
447        push_list_threads_query(&mut builder, filters, relation_filter, limit);
448
449        let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
450        let mut items = Vec::with_capacity(rows.len());
451        let mut parent_thread_ids = std::collections::HashMap::new();
452        for row in rows {
453            let item = ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from)?;
454            if relation_filter.is_some()
455                && let Some(parent_thread_id) =
456                    row.try_get::<Option<String>, _>("parent_thread_id")?
457            {
458                parent_thread_ids.insert(item.id, ThreadId::try_from(parent_thread_id)?);
459            }
460            items.push(item);
461        }
462        let num_scanned_rows = items.len();
463        let next_anchor = if items.len() > page_size {
464            if let Some(overflow_item) = items.pop() {
465                parent_thread_ids.remove(&overflow_item.id);
466            }
467            items.last().and_then(|item| {
468                anchor_from_item(item, filters.sort_key, relation_filter.is_some())
469            })
470        } else {
471            None
472        };
473        Ok(ThreadsPage {
474            items,
475            parent_thread_ids,
476            next_anchor,
477            num_scanned_rows,
478        })
479    }
480
481    /// List thread ids using the underlying database (no rollout scanning).
482    pub async fn list_thread_ids(
483        &self,
484        limit: usize,
485        anchor: Option<&crate::Anchor>,
486        sort_key: crate::SortKey,
487        allowed_sources: &[String],
488        model_providers: Option<&[String]>,
489        archived_only: bool,
490    ) -> anyhow::Result<Vec<ThreadId>> {
491        let mut builder = QueryBuilder::<Sqlite>::new("SELECT threads.id FROM threads");
492        push_thread_filters(
493            &mut builder,
494            ThreadFilterOptions {
495                archived_only,
496                allowed_sources,
497                model_providers,
498                cwd_filters: None,
499                anchor,
500                sort_key,
501                sort_direction: SortDirection::Desc,
502                search_term: None,
503            },
504            sort_key == crate::SortKey::RecencyAt,
505        );
506        push_thread_order_and_limit(
507            &mut builder,
508            sort_key,
509            SortDirection::Desc,
510            OrderByIndex::Enabled,
511            sort_key == crate::SortKey::RecencyAt,
512            limit,
513        );
514
515        let rows = builder.build().fetch_all(self.pool.as_ref()).await?;
516        rows.into_iter()
517            .map(|row| {
518                let id: String = row.try_get("id")?;
519                Ok(ThreadId::try_from(id)?)
520            })
521            .collect()
522    }
523
524    /// Insert or replace thread metadata directly.
525    pub async fn upsert_thread(&self, metadata: &crate::ThreadMetadata) -> anyhow::Result<()> {
526        self.upsert_thread_with_creation_memory_mode(metadata, /*creation_memory_mode*/ None)
527            .await
528    }
529
530    pub async fn insert_thread_if_absent(
531        &self,
532        metadata: &crate::ThreadMetadata,
533    ) -> anyhow::Result<bool> {
534        let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
535        let recency_at = self.allocate_thread_recency_at(metadata.recency_at)?;
536        let preview = metadata_preview(metadata);
537        let result = sqlx::query(
538            r#"
539INSERT INTO threads (
540    id,
541    rollout_path,
542    created_at,
543    updated_at,
544    recency_at,
545    created_at_ms,
546    updated_at_ms,
547    recency_at_ms,
548    source,
549    history_mode,
550    thread_source,
551    agent_nickname,
552    agent_role,
553    agent_path,
554    model_provider,
555    model,
556    reasoning_effort,
557    cwd,
558    cli_version,
559    title,
560    name,
561    preview,
562    sandbox_policy,
563    approval_mode,
564    tokens_used,
565    first_user_message,
566    archived,
567    archived_at,
568    git_sha,
569    git_branch,
570    git_origin_url,
571    memory_mode
572) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
573ON CONFLICT(id) DO NOTHING
574            "#,
575        )
576        .bind(metadata.id.to_string())
577        .bind(metadata.rollout_path.display().to_string())
578        .bind(datetime_to_epoch_seconds(metadata.created_at))
579        .bind(datetime_to_epoch_seconds(updated_at))
580        .bind(datetime_to_epoch_seconds(recency_at))
581        .bind(datetime_to_epoch_millis(metadata.created_at))
582        .bind(datetime_to_epoch_millis(updated_at))
583        .bind(datetime_to_epoch_millis(recency_at))
584        .bind(metadata.source.as_str())
585        .bind(metadata.history_mode.as_str())
586        .bind(
587            metadata
588                .thread_source
589                .as_ref()
590                .map(codex_protocol::protocol::ThreadSource::as_str),
591        )
592        .bind(metadata.agent_nickname.as_deref())
593        .bind(metadata.agent_role.as_deref())
594        .bind(metadata.agent_path.as_deref())
595        .bind(metadata.model_provider.as_str())
596        .bind(metadata.model.as_deref())
597        .bind(
598            metadata
599                .reasoning_effort
600                .as_ref()
601                .map(crate::extract::enum_to_string),
602        )
603        .bind(metadata.cwd.display().to_string())
604        .bind(metadata.cli_version.as_str())
605        .bind(metadata.title.as_str())
606        .bind(metadata.name.as_deref())
607        .bind(preview)
608        .bind(metadata.sandbox_policy.as_str())
609        .bind(metadata.approval_mode.as_str())
610        .bind(metadata.tokens_used)
611        .bind(metadata.first_user_message.as_deref().unwrap_or_default())
612        .bind(metadata.archived_at.is_some())
613        .bind(metadata.archived_at.map(datetime_to_epoch_seconds))
614        .bind(metadata.git_sha.as_deref())
615        .bind(metadata.git_branch.as_deref())
616        .bind(metadata.git_origin_url.as_deref())
617        .bind("enabled")
618        .execute(self.pool.as_ref())
619        .await?;
620        self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
621            .await?;
622        Ok(result.rows_affected() > 0)
623    }
624
625    pub async fn set_thread_memory_mode(
626        &self,
627        thread_id: ThreadId,
628        memory_mode: &str,
629    ) -> anyhow::Result<bool> {
630        let result = sqlx::query("UPDATE threads SET memory_mode = ? WHERE id = ?")
631            .bind(memory_mode)
632            .bind(thread_id.to_string())
633            .execute(self.pool.as_ref())
634            .await?;
635        Ok(result.rows_affected() > 0)
636    }
637
638    pub async fn update_thread_title(
639        &self,
640        thread_id: ThreadId,
641        title: &str,
642    ) -> anyhow::Result<bool> {
643        let result = sqlx::query("UPDATE threads SET title = ? WHERE id = ?")
644            .bind(title)
645            .bind(thread_id.to_string())
646            .execute(self.pool.as_ref())
647            .await?;
648        Ok(result.rows_affected() > 0)
649    }
650
651    pub async fn update_thread_name(
652        &self,
653        thread_id: ThreadId,
654        name: Option<&str>,
655    ) -> anyhow::Result<bool> {
656        let result = sqlx::query("UPDATE threads SET name = ? WHERE id = ?")
657            .bind(name)
658            .bind(thread_id.to_string())
659            .execute(self.pool.as_ref())
660            .await?;
661        Ok(result.rows_affected() > 0)
662    }
663
664    pub async fn touch_thread_updated_at(
665        &self,
666        thread_id: ThreadId,
667        updated_at: DateTime<Utc>,
668    ) -> anyhow::Result<bool> {
669        let updated_at = self.allocate_thread_updated_at(updated_at)?;
670        let result =
671            sqlx::query("UPDATE threads SET updated_at = ?, updated_at_ms = ? WHERE id = ?")
672                .bind(datetime_to_epoch_seconds(updated_at))
673                .bind(datetime_to_epoch_millis(updated_at))
674                .bind(thread_id.to_string())
675                .execute(self.pool.as_ref())
676                .await?;
677        Ok(result.rows_affected() > 0)
678    }
679
680    pub async fn touch_thread_recency_at(
681        &self,
682        thread_id: ThreadId,
683        recency_at: DateTime<Utc>,
684    ) -> anyhow::Result<bool> {
685        let recency_at = self.allocate_thread_recency_at(recency_at)?;
686        let recency_at_seconds = datetime_to_epoch_seconds(recency_at);
687        let recency_at_millis = datetime_to_epoch_millis(recency_at);
688        let result = sqlx::query(
689            r#"
690UPDATE threads
691SET
692    recency_at = MAX(?, MAX(?, recency_at_ms + 1) / 1000),
693    recency_at_ms = MAX(?, recency_at_ms + 1)
694WHERE id = ?
695            "#,
696        )
697        .bind(recency_at_seconds)
698        .bind(recency_at_millis)
699        .bind(recency_at_millis)
700        .bind(thread_id.to_string())
701        .execute(self.pool.as_ref())
702        .await?;
703        Ok(result.rows_affected() > 0)
704    }
705
706    /// Allocate a persisted `updated_at` value for thread-list cursor ordering.
707    ///
708    /// We keep a process-local high-water mark so hot rollout writes can get unique,
709    /// monotonic millisecond timestamps without querying SQLite on every update. Older
710    /// backfill/repair timestamps are allowed through unchanged so historical ordering
711    /// remains tied to the rollout file mtimes.
712    fn allocate_thread_updated_at(
713        &self,
714        updated_at: DateTime<Utc>,
715    ) -> anyhow::Result<DateTime<Utc>> {
716        allocate_thread_timestamp(self.thread_updated_at_millis.as_ref(), updated_at)
717    }
718
719    fn allocate_thread_recency_at(
720        &self,
721        recency_at: DateTime<Utc>,
722    ) -> anyhow::Result<DateTime<Utc>> {
723        allocate_thread_timestamp(self.thread_recency_at_millis.as_ref(), recency_at)
724    }
725}
726
727fn allocate_thread_timestamp(
728    high_water_mark: &AtomicI64,
729    timestamp: DateTime<Utc>,
730) -> anyhow::Result<DateTime<Utc>> {
731    let candidate = datetime_to_epoch_millis(timestamp);
732    let allocated = loop {
733        let current = high_water_mark.load(Ordering::Relaxed);
734
735        // New wall-clock time: advance the process-local high-water mark and use it as-is.
736        if candidate > current {
737            if high_water_mark
738                .compare_exchange(current, candidate, Ordering::Relaxed, Ordering::Relaxed)
739                .is_ok()
740            {
741                break candidate;
742            }
743            continue;
744        }
745
746        // Older timestamps come from backfill/repair paths that preserve rollout mtimes.
747        // Do not drag historical rows forward just because this process has seen newer writes.
748        if candidate.saturating_add(1000) <= current {
749            break candidate;
750        }
751
752        // Same hot one-second bucket as the current high-water mark. Allocate the next
753        // millisecond so the timestamp remains unique and cursor-orderable inside the process.
754        let bumped = current.saturating_add(1);
755        if high_water_mark
756            .compare_exchange(current, bumped, Ordering::Relaxed, Ordering::Relaxed)
757            .is_ok()
758        {
759            break bumped;
760        }
761    };
762    epoch_millis_to_datetime(allocated)
763}
764
765impl StateRuntime {
766    pub async fn update_thread_git_info(
767        &self,
768        thread_id: ThreadId,
769        git_sha: Option<Option<&str>>,
770        git_branch: Option<Option<&str>>,
771        git_origin_url: Option<Option<&str>>,
772    ) -> anyhow::Result<bool> {
773        let result = sqlx::query(
774            r#"
775UPDATE threads
776SET
777    git_sha = CASE WHEN ? THEN ? ELSE git_sha END,
778    git_branch = CASE WHEN ? THEN ? ELSE git_branch END,
779    git_origin_url = CASE WHEN ? THEN ? ELSE git_origin_url END
780WHERE id = ?
781            "#,
782        )
783        .bind(git_sha.is_some())
784        .bind(git_sha.flatten())
785        .bind(git_branch.is_some())
786        .bind(git_branch.flatten())
787        .bind(git_origin_url.is_some())
788        .bind(git_origin_url.flatten())
789        .bind(thread_id.to_string())
790        .execute(self.pool.as_ref())
791        .await?;
792        Ok(result.rows_affected() > 0)
793    }
794
795    async fn upsert_thread_with_creation_memory_mode(
796        &self,
797        metadata: &crate::ThreadMetadata,
798        creation_memory_mode: Option<&str>,
799    ) -> anyhow::Result<()> {
800        let updated_at = self.allocate_thread_updated_at(metadata.updated_at)?;
801        let insert_recency_at = self.allocate_thread_recency_at(metadata.recency_at)?;
802        let preview = metadata_preview(metadata);
803        // Backfill/reconcile callers merge existing git info before upserting, but that
804        // read/modify/write is not atomic. Preserve non-null SQLite git fields here so
805        // an explicit metadata update cannot be lost if a stale rollout upsert lands later.
806        sqlx::query(
807            r#"
808INSERT INTO threads (
809    id,
810    rollout_path,
811    created_at,
812    updated_at,
813    recency_at,
814    created_at_ms,
815    updated_at_ms,
816    recency_at_ms,
817    source,
818    history_mode,
819    thread_source,
820    agent_nickname,
821    agent_role,
822    agent_path,
823    model_provider,
824    model,
825    reasoning_effort,
826    cwd,
827    cli_version,
828    title,
829    name,
830    preview,
831    sandbox_policy,
832    approval_mode,
833    tokens_used,
834    first_user_message,
835    archived,
836    archived_at,
837    git_sha,
838    git_branch,
839    git_origin_url,
840    memory_mode
841) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
842ON CONFLICT(id) DO UPDATE SET
843    rollout_path = excluded.rollout_path,
844    created_at = excluded.created_at,
845    updated_at = excluded.updated_at,
846    recency_at = threads.recency_at,
847    created_at_ms = excluded.created_at_ms,
848    updated_at_ms = excluded.updated_at_ms,
849    recency_at_ms = threads.recency_at_ms,
850    source = excluded.source,
851    history_mode = excluded.history_mode,
852    thread_source = excluded.thread_source,
853    agent_nickname = excluded.agent_nickname,
854    agent_role = excluded.agent_role,
855    agent_path = excluded.agent_path,
856    model_provider = excluded.model_provider,
857    model = excluded.model,
858    reasoning_effort = excluded.reasoning_effort,
859    cwd = excluded.cwd,
860    cli_version = excluded.cli_version,
861    title = excluded.title,
862    preview = COALESCE(NULLIF(excluded.preview, ''), threads.preview),
863    sandbox_policy = excluded.sandbox_policy,
864    approval_mode = excluded.approval_mode,
865    tokens_used = excluded.tokens_used,
866    first_user_message = excluded.first_user_message,
867    archived = excluded.archived,
868    archived_at = excluded.archived_at,
869    git_sha = COALESCE(threads.git_sha, excluded.git_sha),
870    git_branch = COALESCE(threads.git_branch, excluded.git_branch),
871    git_origin_url = COALESCE(threads.git_origin_url, excluded.git_origin_url)
872            "#,
873        )
874        .bind(metadata.id.to_string())
875        .bind(metadata.rollout_path.display().to_string())
876        .bind(datetime_to_epoch_seconds(metadata.created_at))
877        .bind(datetime_to_epoch_seconds(updated_at))
878        .bind(datetime_to_epoch_seconds(insert_recency_at))
879        .bind(datetime_to_epoch_millis(metadata.created_at))
880        .bind(datetime_to_epoch_millis(updated_at))
881        .bind(datetime_to_epoch_millis(insert_recency_at))
882        .bind(metadata.source.as_str())
883        .bind(metadata.history_mode.as_str())
884        .bind(
885            metadata
886                .thread_source
887                .as_ref()
888                .map(codex_protocol::protocol::ThreadSource::as_str),
889        )
890        .bind(metadata.agent_nickname.as_deref())
891        .bind(metadata.agent_role.as_deref())
892        .bind(metadata.agent_path.as_deref())
893        .bind(metadata.model_provider.as_str())
894        .bind(metadata.model.as_deref())
895        .bind(
896            metadata
897                .reasoning_effort
898                .as_ref()
899                .map(crate::extract::enum_to_string),
900        )
901        .bind(metadata.cwd.display().to_string())
902        .bind(metadata.cli_version.as_str())
903        .bind(metadata.title.as_str())
904        .bind(metadata.name.as_deref())
905        .bind(preview)
906        .bind(metadata.sandbox_policy.as_str())
907        .bind(metadata.approval_mode.as_str())
908        .bind(metadata.tokens_used)
909        .bind(metadata.first_user_message.as_deref().unwrap_or_default())
910        .bind(metadata.archived_at.is_some())
911        .bind(metadata.archived_at.map(datetime_to_epoch_seconds))
912        .bind(metadata.git_sha.as_deref())
913        .bind(metadata.git_branch.as_deref())
914        .bind(metadata.git_origin_url.as_deref())
915        .bind(creation_memory_mode.unwrap_or("enabled"))
916        .execute(self.pool.as_ref())
917        .await?;
918        self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
919            .await?;
920        Ok(())
921    }
922
923    /// Apply rollout items incrementally using the underlying database.
924    pub async fn apply_rollout_items(
925        &self,
926        builder: &ThreadMetadataBuilder,
927        items: &[RolloutItem],
928        new_thread_memory_mode: Option<&str>,
929        updated_at_override: Option<DateTime<Utc>>,
930    ) -> anyhow::Result<()> {
931        if items.is_empty() {
932            return Ok(());
933        }
934        let existing_metadata = self.get_thread(builder.id).await?;
935        let mut metadata = existing_metadata
936            .clone()
937            .unwrap_or_else(|| builder.build(&self.default_provider));
938        metadata.rollout_path = builder.rollout_path.clone();
939        for item in items {
940            apply_rollout_item(&mut metadata, item, &self.default_provider);
941        }
942        if let Some(existing_metadata) = existing_metadata.as_ref() {
943            metadata.prefer_existing_git_info(existing_metadata);
944        }
945        let updated_at = match updated_at_override {
946            Some(updated_at) => Some(updated_at),
947            None => file_modified_time_utc(builder.rollout_path.as_path()).await,
948        };
949        if let Some(updated_at) = updated_at {
950            metadata.updated_at = updated_at;
951        }
952        let upsert_result = if existing_metadata.is_none() {
953            self.upsert_thread_with_creation_memory_mode(&metadata, new_thread_memory_mode)
954                .await
955        } else {
956            self.upsert_thread(&metadata).await
957        };
958        upsert_result?;
959        if let Some(memory_mode) = extract_memory_mode(items)
960            && let Err(err) = self
961                .set_thread_memory_mode(builder.id, memory_mode.as_str())
962                .await
963        {
964            return Err(err);
965        }
966        Ok(())
967    }
968
969    /// Mark a thread as archived using the underlying database.
970    pub async fn mark_archived(
971        &self,
972        thread_id: ThreadId,
973        rollout_path: &Path,
974        archived_at: DateTime<Utc>,
975    ) -> anyhow::Result<()> {
976        let Some(mut metadata) = self.get_thread(thread_id).await? else {
977            return Ok(());
978        };
979        metadata.archived_at = Some(archived_at);
980        metadata.rollout_path = rollout_path.to_path_buf();
981        if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
982            metadata.updated_at = updated_at;
983        }
984        if metadata.id != thread_id {
985            warn!(
986                "thread id mismatch during archive: expected {thread_id}, got {}",
987                metadata.id
988            );
989        }
990        self.upsert_thread(&metadata).await
991    }
992
993    /// Mark a thread as unarchived using the underlying database.
994    pub async fn mark_unarchived(
995        &self,
996        thread_id: ThreadId,
997        rollout_path: &Path,
998    ) -> anyhow::Result<()> {
999        let Some(mut metadata) = self.get_thread(thread_id).await? else {
1000            return Ok(());
1001        };
1002        metadata.archived_at = None;
1003        metadata.rollout_path = rollout_path.to_path_buf();
1004        if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
1005            metadata.updated_at = updated_at;
1006        }
1007        if metadata.id != thread_id {
1008            warn!(
1009                "thread id mismatch during unarchive: expected {thread_id}, got {}",
1010                metadata.id
1011            );
1012        }
1013        self.upsert_thread(&metadata).await
1014    }
1015
1016    /// Delete a thread and all associated state by id.
1017    pub async fn delete_thread(&self, thread_id: ThreadId) -> anyhow::Result<u64> {
1018        self.delete_threads_strict(&[thread_id]).await
1019    }
1020
1021    /// Delete a set of threads and all associated state.
1022    ///
1023    /// Spawn edges and thread rows are deleted last so a failed delete can be retried with enough
1024    /// state left to rediscover the same spawned subtree.
1025    pub async fn delete_threads_strict(&self, thread_ids: &[ThreadId]) -> anyhow::Result<u64> {
1026        if thread_ids.is_empty() {
1027            return Ok(0);
1028        }
1029
1030        let thread_id_strings = thread_ids
1031            .iter()
1032            .map(ThreadId::to_string)
1033            .collect::<Vec<_>>();
1034        for (thread_id, thread_id_string) in thread_ids.iter().zip(&thread_id_strings) {
1035            sqlx::query("DELETE FROM logs WHERE thread_id = ?")
1036                .bind(thread_id_string)
1037                .execute(self.logs_pool.as_ref())
1038                .await?;
1039            self.memories.delete_thread_memory(*thread_id).await?;
1040            self.thread_goals.delete_thread_goal(*thread_id).await?;
1041        }
1042
1043        let mut tx = self.pool.begin().await?;
1044        for thread_id_string in &thread_id_strings {
1045            sqlx::query("DELETE FROM thread_dynamic_tools WHERE thread_id = ?")
1046                .bind(thread_id_string)
1047                .execute(&mut *tx)
1048                .await?;
1049        }
1050        for thread_id_string in &thread_id_strings {
1051            sqlx::query(
1052                "DELETE FROM thread_spawn_edges WHERE parent_thread_id = ? OR child_thread_id = ?",
1053            )
1054            .bind(thread_id_string)
1055            .bind(thread_id_string)
1056            .execute(&mut *tx)
1057            .await?;
1058        }
1059        let mut rows_affected = 0;
1060        for thread_id_string in &thread_id_strings {
1061            rows_affected += sqlx::query("DELETE FROM threads WHERE id = ?")
1062                .bind(thread_id_string)
1063                .execute(&mut *tx)
1064                .await?
1065                .rows_affected();
1066        }
1067        tx.commit().await?;
1068
1069        Ok(rows_affected)
1070    }
1071}
1072
1073fn one_thread_id_from_rows(
1074    rows: Vec<sqlx::sqlite::SqliteRow>,
1075    agent_path: &str,
1076) -> anyhow::Result<Option<ThreadId>> {
1077    let mut ids = rows
1078        .into_iter()
1079        .map(|row| {
1080            let id: String = row.try_get("id")?;
1081            ThreadId::try_from(id).map_err(anyhow::Error::from)
1082        })
1083        .collect::<Result<Vec<_>, _>>()?;
1084    match ids.len() {
1085        0 => Ok(None),
1086        1 => Ok(ids.pop()),
1087        _ => Err(anyhow::anyhow!(
1088            "multiple agents found for canonical path `{agent_path}`"
1089        )),
1090    }
1091}
1092
1093fn push_list_threads_query(
1094    builder: &mut QueryBuilder<Sqlite>,
1095    filters: ThreadFilterOptions<'_>,
1096    relation_filter: Option<crate::ThreadRelationFilter>,
1097    limit: usize,
1098) {
1099    if let Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) = relation_filter {
1100        builder.push(
1101            r#"
1102WITH RECURSIVE subtree(child_thread_id, parent_thread_id) AS (
1103    SELECT child_thread_id, parent_thread_id
1104    FROM thread_spawn_edges
1105    WHERE parent_thread_id =
1106"#,
1107        );
1108        builder.push_bind(ancestor_thread_id.to_string());
1109        builder.push(
1110            r#"
1111    UNION
1112    SELECT edge.child_thread_id, edge.parent_thread_id
1113    FROM thread_spawn_edges AS edge
1114    JOIN subtree ON edge.parent_thread_id = subtree.child_thread_id
1115)
1116"#,
1117        );
1118    }
1119    push_thread_select_columns(builder);
1120    // SQLite may otherwise reorder these joins and scan the global timestamp index before
1121    // checking the relationship. CROSS JOIN keeps the selective edge/subtree traversal first.
1122    match relation_filter {
1123        Some(crate::ThreadRelationFilter::DirectChildrenOf(_)) => builder.push(
1124            ", listed_edge.parent_thread_id AS parent_thread_id\nFROM thread_spawn_edges AS listed_edge\nCROSS JOIN threads ON threads.id = listed_edge.child_thread_id",
1125        ),
1126        Some(crate::ThreadRelationFilter::DescendantsOf(_)) => builder.push(
1127            ", subtree.parent_thread_id AS parent_thread_id\nFROM subtree\nCROSS JOIN threads ON threads.id = subtree.child_thread_id",
1128        ),
1129        None => builder.push(" FROM threads"),
1130    };
1131    let include_thread_id_tiebreaker =
1132        relation_filter.is_some() || filters.sort_key == SortKey::RecencyAt;
1133    push_thread_filters(builder, filters, include_thread_id_tiebreaker);
1134    match relation_filter {
1135        Some(crate::ThreadRelationFilter::DirectChildrenOf(parent_thread_id)) => {
1136            builder.push(" AND listed_edge.parent_thread_id = ");
1137            builder.push_bind(parent_thread_id.to_string());
1138        }
1139        Some(crate::ThreadRelationFilter::DescendantsOf(ancestor_thread_id)) => {
1140            builder.push(" AND subtree.child_thread_id != ");
1141            builder.push_bind(ancestor_thread_id.to_string());
1142        }
1143        None => {}
1144    }
1145    let order_by_index = match (relation_filter, filters.cwd_filters) {
1146        // Relationship listings are expected to be much smaller than the global thread table.
1147        // Prefer the spawn-edge index and sort the matching subtree instead of scanning the
1148        // timestamp index until enough related threads happen to be found.
1149        (Some(_), _) => OrderByIndex::Disabled,
1150        // Multi-cwd listing is supported but at the time of writing has no current use in production.
1151        // Preserve its query plan so the global timestamp index does not regress cwd filtering into a scan.
1152        (None, Some(cwd_filters)) if cwd_filters.len() > 1 => OrderByIndex::Disabled,
1153        (None, Some(_) | None) => OrderByIndex::Enabled,
1154    };
1155    push_thread_order_and_limit(
1156        builder,
1157        filters.sort_key,
1158        filters.sort_direction,
1159        order_by_index,
1160        include_thread_id_tiebreaker,
1161        limit,
1162    );
1163}
1164
1165pub(super) fn push_thread_select_columns(builder: &mut QueryBuilder<Sqlite>) {
1166    builder.push(
1167        r#"
1168SELECT
1169    threads.id,
1170    threads.rollout_path,
1171    threads.created_at_ms AS created_at,
1172    threads.updated_at_ms AS updated_at,
1173    threads.recency_at_ms AS recency_at,
1174    threads.source,
1175    threads.history_mode,
1176    threads.thread_source,
1177    threads.agent_nickname,
1178    threads.agent_role,
1179    threads.agent_path,
1180    threads.model_provider,
1181    threads.model,
1182    threads.reasoning_effort,
1183    threads.cwd,
1184    threads.cli_version,
1185    threads.title,
1186    threads.name,
1187    threads.preview,
1188    threads.sandbox_policy,
1189    threads.approval_mode,
1190    threads.tokens_used,
1191    threads.first_user_message,
1192    threads.archived_at,
1193    threads.git_sha,
1194    threads.git_branch,
1195    threads.git_origin_url
1196"#,
1197    );
1198}
1199
1200pub(super) fn extract_memory_mode(items: &[RolloutItem]) -> Option<String> {
1201    items.iter().rev().find_map(|item| match item {
1202        RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(),
1203        RolloutItem::ResponseItem(_)
1204        | RolloutItem::InterAgentCommunication(_)
1205        | RolloutItem::InterAgentCommunicationMetadata { .. }
1206        | RolloutItem::Compacted(_)
1207        | RolloutItem::TurnContext(_)
1208        | RolloutItem::WorldState(_)
1209        | RolloutItem::EventMsg(_) => None,
1210    })
1211}
1212
1213fn thread_spawn_parent_thread_id_from_source_str(source: &str) -> Option<ThreadId> {
1214    let parsed_source = serde_json::from_str(source)
1215        .or_else(|_| serde_json::from_value::<SessionSource>(Value::String(source.to_string())));
1216    parsed_source.ok()?.parent_thread_id()
1217}
1218
1219#[derive(Clone, Copy)]
1220pub struct ThreadFilterOptions<'a> {
1221    pub archived_only: bool,
1222    pub allowed_sources: &'a [String],
1223    pub model_providers: Option<&'a [String]>,
1224    pub cwd_filters: Option<&'a [PathBuf]>,
1225    pub anchor: Option<&'a crate::Anchor>,
1226    pub sort_key: SortKey,
1227    pub sort_direction: SortDirection,
1228    pub search_term: Option<&'a str>,
1229}
1230
1231pub(super) fn push_thread_filters<'a>(
1232    builder: &mut QueryBuilder<Sqlite>,
1233    options: ThreadFilterOptions<'a>,
1234    include_thread_id_tiebreaker: bool,
1235) {
1236    let ThreadFilterOptions {
1237        archived_only,
1238        allowed_sources,
1239        model_providers,
1240        cwd_filters,
1241        anchor,
1242        sort_key,
1243        sort_direction,
1244        search_term,
1245    } = options;
1246    builder.push(" WHERE 1 = 1");
1247    if archived_only {
1248        builder.push(" AND threads.archived = 1");
1249    } else {
1250        builder.push(" AND threads.archived = 0");
1251    }
1252    builder.push(" AND threads.preview <> ''");
1253    if !allowed_sources.is_empty() {
1254        builder.push(" AND threads.source IN (");
1255        let mut separated = builder.separated(", ");
1256        for source in allowed_sources {
1257            separated.push_bind(source);
1258        }
1259        separated.push_unseparated(")");
1260    }
1261    if let Some(model_providers) = model_providers
1262        && !model_providers.is_empty()
1263    {
1264        builder.push(" AND threads.model_provider IN (");
1265        let mut separated = builder.separated(", ");
1266        for provider in model_providers {
1267            separated.push_bind(provider);
1268        }
1269        separated.push_unseparated(")");
1270    }
1271    match cwd_filters {
1272        Some([]) => {
1273            builder.push(" AND 1 = 0");
1274        }
1275        Some(cwd_filters) => {
1276            builder.push(" AND threads.cwd IN (");
1277            let mut separated = builder.separated(", ");
1278            for cwd in cwd_filters {
1279                separated.push_bind(cwd.display().to_string());
1280            }
1281            separated.push_unseparated(")");
1282        }
1283        None => {}
1284    }
1285    if let Some(search_term) = search_term {
1286        builder.push(" AND (instr(COALESCE(threads.name, ''), ");
1287        builder.push_bind(search_term);
1288        builder.push(") > 0 OR instr(threads.title, ");
1289        builder.push_bind(search_term);
1290        builder.push(") > 0 OR instr(threads.preview, ");
1291        builder.push_bind(search_term);
1292        builder.push(") > 0)");
1293    }
1294    if let Some(anchor) = anchor {
1295        let anchor_ts = datetime_to_epoch_millis(anchor.ts);
1296        let column = match sort_key {
1297            SortKey::CreatedAt => "threads.created_at_ms",
1298            SortKey::UpdatedAt => "threads.updated_at_ms",
1299            SortKey::RecencyAt => "threads.recency_at_ms",
1300        };
1301        let operator = match sort_direction {
1302            SortDirection::Asc => ">",
1303            SortDirection::Desc => "<",
1304        };
1305        builder.push(" AND (");
1306        builder.push(column);
1307        builder.push(" ");
1308        builder.push(operator);
1309        builder.push(" ");
1310        builder.push_bind(anchor_ts);
1311        if include_thread_id_tiebreaker && let Some(anchor_id) = anchor.id {
1312            builder.push(" OR (");
1313            builder.push(column);
1314            builder.push(" = ");
1315            builder.push_bind(anchor_ts);
1316            builder.push(" AND threads.id ");
1317            builder.push(operator);
1318            builder.push(" ");
1319            builder.push_bind(anchor_id.to_string());
1320            builder.push(")");
1321        }
1322        builder.push(")");
1323    }
1324}
1325
1326/// Controls whether SQLite may use the ordered column to satisfy `ORDER BY` from an index.
1327///
1328/// Disabling it adds a unary `+` to the ordered column. This preserves the sort semantics while
1329/// preventing a timestamp-only index from winning over a more selective filtering index.
1330#[derive(Clone, Copy)]
1331pub(super) enum OrderByIndex {
1332    Enabled,
1333    Disabled,
1334}
1335
1336pub(super) fn push_thread_order_and_limit(
1337    builder: &mut QueryBuilder<Sqlite>,
1338    sort_key: SortKey,
1339    sort_direction: SortDirection,
1340    order_by_index: OrderByIndex,
1341    include_thread_id_tiebreaker: bool,
1342    limit: usize,
1343) {
1344    let order_column = match sort_key {
1345        SortKey::CreatedAt => "threads.created_at_ms",
1346        SortKey::UpdatedAt => "threads.updated_at_ms",
1347        SortKey::RecencyAt => "threads.recency_at_ms",
1348    };
1349    let order_direction = match sort_direction {
1350        SortDirection::Asc => "ASC",
1351        SortDirection::Desc => "DESC",
1352    };
1353    builder.push(" ORDER BY ");
1354    match order_by_index {
1355        OrderByIndex::Enabled => {}
1356        OrderByIndex::Disabled => {
1357            builder.push("+");
1358        }
1359    }
1360    builder.push(order_column);
1361    builder.push(" ");
1362    builder.push(order_direction);
1363    if include_thread_id_tiebreaker {
1364        builder.push(", threads.id ");
1365        builder.push(order_direction);
1366    }
1367    builder.push(" LIMIT ");
1368    builder.push_bind(limit as i64);
1369}
1370
1371fn metadata_preview(metadata: &crate::ThreadMetadata) -> &str {
1372    metadata
1373        .preview
1374        .as_deref()
1375        .or(metadata.first_user_message.as_deref())
1376        .unwrap_or_default()
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381    use super::*;
1382    use crate::Anchor;
1383    use crate::DirectionalThreadSpawnEdgeStatus;
1384    use crate::runtime::test_support::test_thread_metadata;
1385    use crate::runtime::test_support::unique_temp_dir;
1386    use anyhow::Result;
1387    use codex_protocol::protocol::EventMsg;
1388    use codex_protocol::protocol::GitInfo;
1389    use codex_protocol::protocol::SessionMeta;
1390    use codex_protocol::protocol::SessionMetaLine;
1391    use codex_protocol::protocol::SessionSource;
1392    use codex_protocol::protocol::ThreadHistoryMode;
1393    use pretty_assertions::assert_eq;
1394    use std::path::PathBuf;
1395
1396    #[tokio::test]
1397    async fn upsert_thread_keeps_creation_memory_mode_for_existing_rows() {
1398        let codex_home = unique_temp_dir();
1399        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1400            .await
1401            .expect("state db should initialize");
1402        let thread_id =
1403            ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
1404        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
1405
1406        runtime
1407            .upsert_thread_with_creation_memory_mode(&metadata, Some("disabled"))
1408            .await
1409            .expect("initial insert should succeed");
1410
1411        let memory_mode: String =
1412            sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
1413                .bind(thread_id.to_string())
1414                .fetch_one(runtime.pool.as_ref())
1415                .await
1416                .expect("memory mode should be readable");
1417        assert_eq!(memory_mode, "disabled");
1418
1419        metadata.title = "updated title".to_string();
1420        runtime
1421            .upsert_thread(&metadata)
1422            .await
1423            .expect("upsert should succeed");
1424
1425        let memory_mode: String =
1426            sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
1427                .bind(thread_id.to_string())
1428                .fetch_one(runtime.pool.as_ref())
1429                .await
1430                .expect("memory mode should remain readable");
1431        assert_eq!(memory_mode, "disabled");
1432    }
1433
1434    #[tokio::test]
1435    async fn thread_metadata_round_trips_history_mode() {
1436        let codex_home = unique_temp_dir();
1437        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1438            .await
1439            .expect("state db should initialize");
1440        let thread_id =
1441            ThreadId::from_string("00000000-0000-0000-0000-000000000124").expect("valid thread id");
1442        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
1443        metadata.history_mode = ThreadHistoryMode::Paginated;
1444
1445        runtime
1446            .upsert_thread(&metadata)
1447            .await
1448            .expect("upsert should succeed");
1449
1450        let metadata = runtime
1451            .get_thread(thread_id)
1452            .await
1453            .expect("thread should load")
1454            .expect("thread should exist");
1455        assert_eq!(metadata.history_mode, ThreadHistoryMode::Paginated);
1456    }
1457
1458    #[tokio::test]
1459    async fn delete_thread_cleans_associated_state() -> Result<()> {
1460        let codex_home = unique_temp_dir();
1461        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()).await?;
1462        let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000401")?;
1463        let child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000402")?;
1464        runtime
1465            .upsert_thread(&test_thread_metadata(
1466                &codex_home,
1467                thread_id,
1468                codex_home.clone(),
1469            ))
1470            .await?;
1471        seed_thread_cleanup_state(&runtime, thread_id, child_thread_id).await?;
1472        sqlx::query("INSERT INTO thread_dynamic_tools (thread_id, position, name, description, input_schema) VALUES (?, ?, ?, ?, ?)")
1473        .bind(thread_id.to_string())
1474        .bind(0_i64)
1475        .bind("test_tool")
1476        .bind("test dynamic tool")
1477        .bind("{}")
1478        .execute(runtime.pool.as_ref())
1479        .await?;
1480        let rows = runtime
1481            .delete_threads_strict(&[thread_id, child_thread_id])
1482            .await?;
1483
1484        assert_eq!(rows, 1);
1485        assert!(runtime.get_thread(thread_id).await?.is_none());
1486        let dynamic_tool_count: i64 =
1487            sqlx::query_scalar("SELECT COUNT(*) FROM thread_dynamic_tools WHERE thread_id = ?")
1488                .bind(thread_id.to_string())
1489                .fetch_one(runtime.pool.as_ref())
1490                .await?;
1491        assert_eq!(dynamic_tool_count, 0);
1492        assert_thread_cleanup_state(&runtime, thread_id).await?;
1493
1494        let missing_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000403")?;
1495        let missing_child_thread_id =
1496            ThreadId::from_string("00000000-0000-0000-0000-000000000404")?;
1497        seed_thread_cleanup_state(&runtime, missing_thread_id, missing_child_thread_id).await?;
1498
1499        assert_eq!(runtime.delete_thread(missing_thread_id).await?, 0);
1500        assert_thread_cleanup_state(&runtime, missing_thread_id).await?;
1501        Ok(())
1502    }
1503
1504    #[tokio::test]
1505    async fn delete_thread_keeps_retry_graph_on_cleanup_failure() -> Result<()> {
1506        let codex_home = unique_temp_dir();
1507        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()).await?;
1508        let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000405")?;
1509        let child_thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000406")?;
1510        runtime
1511            .upsert_thread(&test_thread_metadata(
1512                &codex_home,
1513                thread_id,
1514                codex_home.clone(),
1515            ))
1516            .await?;
1517        seed_thread_cleanup_state(&runtime, thread_id, child_thread_id).await?;
1518
1519        runtime.logs_pool.close().await;
1520        runtime
1521            .delete_thread(thread_id)
1522            .await
1523            .expect_err("closed log db should fail deletion");
1524
1525        assert!(runtime.get_thread(thread_id).await?.is_some());
1526        assert_eq!(
1527            runtime.list_thread_spawn_descendants(thread_id).await?,
1528            vec![child_thread_id]
1529        );
1530        Ok(())
1531    }
1532
1533    async fn seed_thread_cleanup_state(
1534        runtime: &StateRuntime,
1535        thread_id: ThreadId,
1536        child_thread_id: ThreadId,
1537    ) -> Result<()> {
1538        runtime
1539            .upsert_thread_spawn_edge(
1540                thread_id,
1541                child_thread_id,
1542                DirectionalThreadSpawnEdgeStatus::Closed,
1543            )
1544            .await?;
1545        runtime
1546            .thread_goals()
1547            .replace_thread_goal(
1548                thread_id,
1549                "test goal",
1550                crate::ThreadGoalStatus::Active,
1551                /*token_budget*/ None,
1552            )
1553            .await?;
1554        sqlx::query("INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, thread_id) VALUES (1, 0, 'INFO', 'test', 'feedback log', ?)")
1555            .bind(thread_id.to_string())
1556            .execute(runtime.logs_pool.as_ref())
1557            .await?;
1558        Ok(())
1559    }
1560
1561    async fn assert_thread_cleanup_state(
1562        runtime: &StateRuntime,
1563        thread_id: ThreadId,
1564    ) -> Result<()> {
1565        let spawn_edge_count: i64 = sqlx::query_scalar(
1566            "SELECT COUNT(*) FROM thread_spawn_edges WHERE parent_thread_id = ? OR child_thread_id = ?",
1567        )
1568        .bind(thread_id.to_string())
1569        .bind(thread_id.to_string())
1570        .fetch_one(runtime.pool.as_ref())
1571        .await?;
1572        assert_eq!(spawn_edge_count, 0);
1573        assert_eq!(
1574            runtime.thread_goals().get_thread_goal(thread_id).await?,
1575            None
1576        );
1577        let logs = runtime
1578            .query_logs(&LogQuery {
1579                thread_ids: vec![thread_id.to_string()],
1580                ..Default::default()
1581            })
1582            .await?;
1583        assert!(logs.is_empty());
1584        Ok(())
1585    }
1586
1587    #[tokio::test]
1588    async fn list_threads_updated_after_returns_oldest_changes_first() {
1589        let codex_home = unique_temp_dir();
1590        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1591            .await
1592            .expect("state db should initialize");
1593        let older_id =
1594            ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id");
1595        let middle_id =
1596            ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id");
1597        let newer_id =
1598            ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread id");
1599        let older_updated_at =
1600            DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("valid older timestamp");
1601        let newer_updated_at =
1602            DateTime::<Utc>::from_timestamp(1_700_000_200, 0).expect("valid newer timestamp");
1603
1604        for (thread_id, updated_at) in [
1605            (older_id, older_updated_at),
1606            (newer_id, newer_updated_at),
1607            (middle_id, newer_updated_at),
1608        ] {
1609            let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
1610            metadata.updated_at = updated_at;
1611            metadata.first_user_message = Some("hello".to_string());
1612            runtime
1613                .upsert_thread(&metadata)
1614                .await
1615                .expect("thread insert should succeed");
1616        }
1617
1618        let anchor = Anchor {
1619            ts: older_updated_at,
1620            id: None,
1621        };
1622        let model_providers = ["test-provider".to_string()];
1623        let page = runtime
1624            .list_threads(
1625                /*page_size*/ 1,
1626                ThreadFilterOptions {
1627                    archived_only: false,
1628                    allowed_sources: &[],
1629                    model_providers: Some(&model_providers),
1630                    cwd_filters: None,
1631                    anchor: Some(&anchor),
1632                    sort_key: SortKey::UpdatedAt,
1633                    sort_direction: SortDirection::Asc,
1634                    search_term: None,
1635                },
1636            )
1637            .await
1638            .expect("list should succeed");
1639
1640        let ids = page.items.iter().map(|item| item.id).collect::<Vec<_>>();
1641        assert_eq!(ids, vec![newer_id]);
1642        assert_eq!(
1643            page.next_anchor,
1644            Some(Anchor {
1645                ts: DateTime::<Utc>::from_timestamp_millis(1_700_000_200_000)
1646                    .expect("valid timestamp"),
1647                id: None,
1648            })
1649        );
1650
1651        let page = runtime
1652            .list_threads(
1653                /*page_size*/ 1,
1654                ThreadFilterOptions {
1655                    archived_only: false,
1656                    allowed_sources: &[],
1657                    model_providers: Some(&model_providers),
1658                    cwd_filters: None,
1659                    anchor: page.next_anchor.as_ref(),
1660                    sort_key: SortKey::UpdatedAt,
1661                    sort_direction: SortDirection::Asc,
1662                    search_term: None,
1663                },
1664            )
1665            .await
1666            .expect("second page should succeed");
1667
1668        let ids = page.items.iter().map(|item| item.id).collect::<Vec<_>>();
1669        assert_eq!(ids, vec![middle_id]);
1670        assert_eq!(page.next_anchor, None);
1671    }
1672
1673    #[tokio::test]
1674    async fn list_threads_filters_by_cwd() {
1675        let codex_home = unique_temp_dir();
1676        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1677            .await
1678            .expect("state db should initialize");
1679        let first_id =
1680            ThreadId::from_string("00000000-0000-0000-0000-000000000101").expect("valid thread id");
1681        let second_id =
1682            ThreadId::from_string("00000000-0000-0000-0000-000000000102").expect("valid thread id");
1683        let other_id =
1684            ThreadId::from_string("00000000-0000-0000-0000-000000000103").expect("valid thread id");
1685        let first_cwd = codex_home.join("first");
1686        let second_cwd = codex_home.join("second");
1687        let other_cwd = codex_home.join("other");
1688
1689        for (thread_id, cwd, updated_at) in [
1690            (first_id, first_cwd.clone(), 1_700_000_100),
1691            (second_id, second_cwd.clone(), 1_700_000_300),
1692            (other_id, other_cwd, 1_700_000_500),
1693        ] {
1694            let mut metadata = test_thread_metadata(&codex_home, thread_id, cwd);
1695            metadata.updated_at =
1696                DateTime::<Utc>::from_timestamp(updated_at, 0).expect("valid timestamp");
1697            runtime
1698                .upsert_thread(&metadata)
1699                .await
1700                .expect("thread insert should succeed");
1701        }
1702
1703        let cwd_filters = vec![first_cwd, second_cwd];
1704        let first_page = runtime
1705            .list_threads(
1706                /*page_size*/ 1,
1707                ThreadFilterOptions {
1708                    archived_only: false,
1709                    allowed_sources: &[],
1710                    model_providers: None,
1711                    cwd_filters: Some(cwd_filters.as_slice()),
1712                    anchor: None,
1713                    sort_key: SortKey::UpdatedAt,
1714                    sort_direction: SortDirection::Desc,
1715                    search_term: None,
1716                },
1717            )
1718            .await
1719            .expect("list should succeed");
1720
1721        let ids = first_page
1722            .items
1723            .iter()
1724            .map(|item| item.id)
1725            .collect::<Vec<_>>();
1726        assert_eq!(ids, vec![second_id]);
1727        assert_eq!(
1728            first_page.next_anchor,
1729            Some(Anchor {
1730                ts: DateTime::<Utc>::from_timestamp_millis(1_700_000_300_000)
1731                    .expect("valid timestamp"),
1732                id: None,
1733            })
1734        );
1735
1736        let second_page = runtime
1737            .list_threads(
1738                /*page_size*/ 1,
1739                ThreadFilterOptions {
1740                    archived_only: false,
1741                    allowed_sources: &[],
1742                    model_providers: None,
1743                    cwd_filters: Some(cwd_filters.as_slice()),
1744                    anchor: first_page.next_anchor.as_ref(),
1745                    sort_key: SortKey::UpdatedAt,
1746                    sort_direction: SortDirection::Desc,
1747                    search_term: None,
1748                },
1749            )
1750            .await
1751            .expect("second page should succeed");
1752
1753        let ids = second_page
1754            .items
1755            .iter()
1756            .map(|item| item.id)
1757            .collect::<Vec<_>>();
1758        assert_eq!(ids, vec![first_id]);
1759        assert_eq!(second_page.next_anchor, None);
1760
1761        let page = runtime
1762            .list_threads(
1763                /*page_size*/ 10,
1764                ThreadFilterOptions {
1765                    archived_only: false,
1766                    allowed_sources: &[],
1767                    model_providers: None,
1768                    cwd_filters: Some(&[]),
1769                    anchor: None,
1770                    sort_key: SortKey::UpdatedAt,
1771                    sort_direction: SortDirection::Desc,
1772                    search_term: None,
1773                },
1774            )
1775            .await
1776            .expect("list with empty cwd filters should succeed");
1777
1778        assert_eq!(page.items, Vec::new());
1779    }
1780
1781    #[tokio::test]
1782    async fn list_threads_uses_indexes_matching_cwd_filters() {
1783        let codex_home = unique_temp_dir();
1784        let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
1785            .await
1786            .expect("state db should initialize");
1787
1788        let model_providers = ["test-provider".to_string()];
1789        let cwd_filters = [
1790            PathBuf::from("/workspace/one"),
1791            PathBuf::from("/workspace/two"),
1792        ];
1793        let anchor = Anchor {
1794            ts: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp"),
1795            id: None,
1796        };
1797        for (sort_key, visible_index, cwd_index) in [
1798            (
1799                SortKey::CreatedAt,
1800                "idx_threads_visible_created_at_ms",
1801                "idx_threads_archived_cwd_created_at_ms",
1802            ),
1803            (
1804                SortKey::UpdatedAt,
1805                "idx_threads_visible_updated_at_ms",
1806                "idx_threads_archived_cwd_updated_at_ms",
1807            ),
1808            (
1809                SortKey::RecencyAt,
1810                "idx_threads_visible_recency_at_ms",
1811                "idx_threads_archived_cwd_recency_at_ms",
1812            ),
1813        ] {
1814            for (cwd_filters, anchor, expected_index, expect_temp_sort) in [
1815                (None, None, visible_index, false),
1816                (Some(&cwd_filters[..1]), None, cwd_index, false),
1817                (
1818                    Some(&cwd_filters[..]),
1819                    None,
1820                    "idx_threads_archived_cwd_",
1821                    true,
1822                ),
1823                (Some(&cwd_filters[..]), Some(&anchor), cwd_index, true),
1824            ] {
1825                let mut builder = QueryBuilder::<Sqlite>::new("EXPLAIN QUERY PLAN ");
1826                push_list_threads_query(
1827                    &mut builder,
1828                    ThreadFilterOptions {
1829                        archived_only: false,
1830                        allowed_sources: &[],
1831                        model_providers: Some(&model_providers),
1832                        cwd_filters,
1833                        anchor,
1834                        sort_key,
1835                        sort_direction: SortDirection::Desc,
1836                        search_term: None,
1837                    },
1838                    /*relation_filter*/ None,
1839                    /*limit*/ 201,
1840                );
1841                let plan_details = builder
1842                    .build()
1843                    .fetch_all(runtime.pool.as_ref())
1844                    .await
1845                    .expect("query plan should load")
1846                    .into_iter()
1847                    .map(|row| row.get::<String, _>("detail"))
1848                    .collect::<Vec<_>>();
1849
1850                assert!(
1851                    plan_details
1852                        .iter()
1853                        .any(|detail| detail.contains(expected_index)),
1854                    "query plan did not use {expected_index}: {plan_details:?}"
1855                );
1856                assert_eq!(
1857                    plan_details
1858                        .iter()
1859                        .any(|detail| detail.contains("TEMP B-TREE")),
1860                    expect_temp_sort,
1861                    "unexpected sorting plan: {plan_details:?}"
1862                );
1863            }
1864        }
1865    }
1866
1867    #[tokio::test]
1868    async fn list_threads_by_relation_filters_spawn_graph_with_keyset_pagination() {
1869        let codex_home = unique_temp_dir();
1870        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1871            .await
1872            .expect("state db should initialize");
1873        let parent_id = ThreadId::new();
1874        let first_child_id =
1875            ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread id");
1876        let second_child_id =
1877            ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread id");
1878        let grandchild_id = ThreadId::new();
1879
1880        for (thread_id, created_at) in [
1881            (parent_id, 1_700_000_000),
1882            (first_child_id, 1_700_000_200),
1883            (second_child_id, 1_700_000_200),
1884            (grandchild_id, 1_700_000_300),
1885        ] {
1886            let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
1887            metadata.created_at =
1888                DateTime::<Utc>::from_timestamp(created_at, 0).expect("valid timestamp");
1889            metadata.updated_at = metadata.created_at;
1890            runtime
1891                .upsert_thread(&metadata)
1892                .await
1893                .expect("thread insert should succeed");
1894        }
1895        for (parent_thread_id, child_thread_id, status) in [
1896            (
1897                parent_id,
1898                first_child_id,
1899                DirectionalThreadSpawnEdgeStatus::Open,
1900            ),
1901            (
1902                parent_id,
1903                second_child_id,
1904                DirectionalThreadSpawnEdgeStatus::Closed,
1905            ),
1906            (
1907                first_child_id,
1908                grandchild_id,
1909                DirectionalThreadSpawnEdgeStatus::Open,
1910            ),
1911        ] {
1912            runtime
1913                .upsert_thread_spawn_edge(parent_thread_id, child_thread_id, status)
1914                .await
1915                .expect("spawn edge insert should succeed");
1916        }
1917
1918        let mut builder = QueryBuilder::<Sqlite>::new("EXPLAIN QUERY PLAN ");
1919        push_list_threads_query(
1920            &mut builder,
1921            ThreadFilterOptions {
1922                archived_only: false,
1923                allowed_sources: &[],
1924                model_providers: None,
1925                cwd_filters: None,
1926                anchor: None,
1927                sort_key: SortKey::CreatedAt,
1928                sort_direction: SortDirection::Desc,
1929                search_term: None,
1930            },
1931            Some(crate::ThreadRelationFilter::DescendantsOf(parent_id)),
1932            /*limit*/ 10,
1933        );
1934        let plan_details = builder
1935            .build()
1936            .fetch_all(runtime.pool.as_ref())
1937            .await
1938            .expect("relationship query plan should load")
1939            .into_iter()
1940            .map(|row| row.get::<String, _>("detail"))
1941            .collect::<Vec<_>>();
1942        assert!(
1943            plan_details
1944                .iter()
1945                .any(|detail| detail.contains("idx_thread_spawn_edges_parent_status")),
1946            "spawn relationship query did not use the parent index: {plan_details:?}"
1947        );
1948
1949        let filters = |anchor| ThreadFilterOptions {
1950            archived_only: false,
1951            allowed_sources: &[],
1952            model_providers: None,
1953            cwd_filters: None,
1954            anchor,
1955            sort_key: SortKey::CreatedAt,
1956            sort_direction: SortDirection::Desc,
1957            search_term: None,
1958        };
1959        let first_page = runtime
1960            .list_threads_by_parent(/*page_size*/ 1, parent_id, filters(None))
1961            .await
1962            .expect("first page should succeed");
1963        let second_page = runtime
1964            .list_threads_by_parent(
1965                /*page_size*/ 1,
1966                parent_id,
1967                filters(first_page.next_anchor.as_ref()),
1968            )
1969            .await
1970            .expect("second page should succeed");
1971
1972        assert_eq!(
1973            first_page
1974                .items
1975                .iter()
1976                .map(|item| item.id)
1977                .collect::<Vec<_>>(),
1978            vec![second_child_id]
1979        );
1980        assert_eq!(
1981            second_page
1982                .items
1983                .iter()
1984                .map(|item| item.id)
1985                .collect::<Vec<_>>(),
1986            vec![first_child_id]
1987        );
1988        assert_eq!(second_page.next_anchor, None);
1989
1990        let first_descendant_page = runtime
1991            .list_threads_by_relation(
1992                /*page_size*/ 2,
1993                crate::ThreadRelationFilter::DescendantsOf(parent_id),
1994                filters(None),
1995            )
1996            .await
1997            .expect("first descendant page should succeed");
1998        let second_descendant_page = runtime
1999            .list_threads_by_relation(
2000                /*page_size*/ 2,
2001                crate::ThreadRelationFilter::DescendantsOf(parent_id),
2002                filters(first_descendant_page.next_anchor.as_ref()),
2003            )
2004            .await
2005            .expect("second descendant page should succeed");
2006        assert_eq!(
2007            (
2008                first_descendant_page
2009                    .items
2010                    .iter()
2011                    .map(|item| item.id)
2012                    .collect::<Vec<_>>(),
2013                second_descendant_page
2014                    .items
2015                    .iter()
2016                    .map(|item| item.id)
2017                    .collect::<Vec<_>>(),
2018                first_descendant_page.parent_thread_ids,
2019                second_descendant_page.parent_thread_ids,
2020                second_descendant_page.next_anchor,
2021            ),
2022            (
2023                vec![grandchild_id, second_child_id],
2024                vec![first_child_id],
2025                [
2026                    (grandchild_id, first_child_id),
2027                    (second_child_id, parent_id)
2028                ]
2029                .into(),
2030                [(first_child_id, parent_id)].into(),
2031                None,
2032            )
2033        );
2034
2035        runtime
2036            .upsert_thread_spawn_edge(
2037                grandchild_id,
2038                parent_id,
2039                DirectionalThreadSpawnEdgeStatus::Open,
2040            )
2041            .await
2042            .expect("cycle-closing spawn edge insert should succeed");
2043        let cyclic_descendants = runtime
2044            .list_threads_by_relation(
2045                /*page_size*/ 10,
2046                crate::ThreadRelationFilter::DescendantsOf(parent_id),
2047                filters(None),
2048            )
2049            .await
2050            .expect("cyclic descendant graph should terminate");
2051        assert_eq!(
2052            cyclic_descendants
2053                .items
2054                .iter()
2055                .map(|item| item.id)
2056                .collect::<Vec<_>>(),
2057            vec![grandchild_id, second_child_id, first_child_id]
2058        );
2059    }
2060
2061    #[tokio::test]
2062    async fn apply_rollout_items_restores_memory_mode_from_session_meta() {
2063        let codex_home = unique_temp_dir();
2064        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2065            .await
2066            .expect("state db should initialize");
2067        let thread_id =
2068            ThreadId::from_string("00000000-0000-0000-0000-000000000456").expect("valid thread id");
2069        let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2070
2071        runtime
2072            .upsert_thread(&metadata)
2073            .await
2074            .expect("initial upsert should succeed");
2075
2076        let builder = ThreadMetadataBuilder::new(
2077            thread_id,
2078            metadata.rollout_path.clone(),
2079            metadata.created_at,
2080            SessionSource::Cli,
2081        );
2082        let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
2083            meta: SessionMeta {
2084                session_id: thread_id.into(),
2085                id: thread_id,
2086                forked_from_id: None,
2087                parent_thread_id: None,
2088                timestamp: metadata.created_at.to_rfc3339(),
2089                cwd: PathBuf::new(),
2090                originator: String::new(),
2091                cli_version: String::new(),
2092                source: SessionSource::Cli,
2093                thread_source: None,
2094                agent_path: None,
2095                agent_nickname: None,
2096                agent_role: None,
2097                model_provider: None,
2098                base_instructions: None,
2099                dynamic_tools: None,
2100                selected_capability_roots: Vec::new(),
2101                memory_mode: Some("polluted".to_string()),
2102                history_mode: Default::default(),
2103                history_base: None,
2104                subagent_history_start_ordinal: None,
2105                multi_agent_version: None,
2106                context_window: None,
2107            },
2108            git: None,
2109        })];
2110
2111        runtime
2112            .apply_rollout_items(
2113                &builder, &items, /*new_thread_memory_mode*/ None,
2114                /*updated_at_override*/ None,
2115            )
2116            .await
2117            .expect("apply_rollout_items should succeed");
2118
2119        let memory_mode = runtime
2120            .get_thread_memory_mode(thread_id)
2121            .await
2122            .expect("memory mode should load");
2123        assert_eq!(memory_mode.as_deref(), Some("polluted"));
2124    }
2125
2126    #[tokio::test]
2127    async fn apply_rollout_items_preserves_existing_git_branch_and_fills_missing_git_fields() {
2128        let codex_home = unique_temp_dir();
2129        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2130            .await
2131            .expect("state db should initialize");
2132        let thread_id =
2133            ThreadId::from_string("00000000-0000-0000-0000-000000000457").expect("valid thread id");
2134        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2135        metadata.git_branch = Some("sqlite-branch".to_string());
2136
2137        runtime
2138            .upsert_thread(&metadata)
2139            .await
2140            .expect("initial upsert should succeed");
2141
2142        let created_at = metadata.created_at.to_rfc3339();
2143        let builder = ThreadMetadataBuilder::new(
2144            thread_id,
2145            metadata.rollout_path.clone(),
2146            metadata.created_at,
2147            SessionSource::Cli,
2148        );
2149        let items = vec![RolloutItem::SessionMeta(SessionMetaLine {
2150            meta: SessionMeta {
2151                session_id: thread_id.into(),
2152                id: thread_id,
2153                forked_from_id: None,
2154                parent_thread_id: None,
2155                timestamp: created_at,
2156                cwd: PathBuf::new(),
2157                originator: String::new(),
2158                cli_version: String::new(),
2159                source: SessionSource::Cli,
2160                thread_source: None,
2161                agent_path: None,
2162                agent_nickname: None,
2163                agent_role: None,
2164                model_provider: None,
2165                base_instructions: None,
2166                dynamic_tools: None,
2167                selected_capability_roots: Vec::new(),
2168                memory_mode: None,
2169                history_mode: Default::default(),
2170                history_base: None,
2171                subagent_history_start_ordinal: None,
2172                multi_agent_version: None,
2173                context_window: None,
2174            },
2175            git: Some(GitInfo {
2176                commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")),
2177                branch: Some("rollout-branch".to_string()),
2178                repository_url: Some("git@example.com:openai/codex.git".to_string()),
2179            }),
2180        })];
2181
2182        runtime
2183            .apply_rollout_items(
2184                &builder, &items, /*new_thread_memory_mode*/ None,
2185                /*updated_at_override*/ None,
2186            )
2187            .await
2188            .expect("apply_rollout_items should succeed");
2189
2190        let persisted = runtime
2191            .get_thread(thread_id)
2192            .await
2193            .expect("thread should load")
2194            .expect("thread should exist");
2195        assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha"));
2196        assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
2197        assert_eq!(
2198            persisted.git_origin_url.as_deref(),
2199            Some("git@example.com:openai/codex.git")
2200        );
2201    }
2202
2203    #[tokio::test]
2204    async fn upsert_thread_preserves_existing_git_fields_atomically() {
2205        let codex_home = unique_temp_dir();
2206        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2207            .await
2208            .expect("state db should initialize");
2209        let thread_id =
2210            ThreadId::from_string("00000000-0000-0000-0000-000000000458").expect("valid thread id");
2211        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2212        metadata.git_sha = Some("sqlite-sha".to_string());
2213        metadata.git_branch = Some("sqlite-branch".to_string());
2214        metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string());
2215
2216        runtime
2217            .upsert_thread(&metadata)
2218            .await
2219            .expect("initial upsert should succeed");
2220
2221        let mut rollout_metadata = metadata.clone();
2222        rollout_metadata.git_sha = Some("rollout-sha".to_string());
2223        rollout_metadata.git_branch = Some("rollout-branch".to_string());
2224        rollout_metadata.git_origin_url = Some("https://example.com/repo.git".to_string());
2225
2226        runtime
2227            .upsert_thread(&rollout_metadata)
2228            .await
2229            .expect("rollout upsert should succeed");
2230
2231        let persisted = runtime
2232            .get_thread(thread_id)
2233            .await
2234            .expect("thread should load")
2235            .expect("thread should exist");
2236        assert_eq!(persisted.git_sha.as_deref(), Some("sqlite-sha"));
2237        assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
2238        assert_eq!(
2239            persisted.git_origin_url.as_deref(),
2240            Some("git@example.com:openai/codex.git")
2241        );
2242    }
2243
2244    #[tokio::test]
2245    async fn upsert_thread_preserves_existing_preview_when_incoming_preview_is_empty() {
2246        let codex_home = unique_temp_dir();
2247        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2248            .await
2249            .expect("state db should initialize");
2250        let thread_id =
2251            ThreadId::from_string("00000000-0000-0000-0000-000000000459").expect("valid thread id");
2252        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2253        metadata.first_user_message = None;
2254        metadata.preview = Some("migrated goal preview".to_string());
2255
2256        runtime
2257            .upsert_thread(&metadata)
2258            .await
2259            .expect("initial upsert should succeed");
2260
2261        let mut rollout_metadata = metadata.clone();
2262        rollout_metadata.preview = None;
2263
2264        runtime
2265            .upsert_thread(&rollout_metadata)
2266            .await
2267            .expect("rollout upsert should succeed");
2268
2269        let persisted = runtime
2270            .get_thread(thread_id)
2271            .await
2272            .expect("thread should load")
2273            .expect("thread should exist");
2274        assert_eq!(persisted.preview.as_deref(), Some("migrated goal preview"));
2275    }
2276
2277    #[tokio::test]
2278    async fn set_thread_preview_if_empty_only_fills_blank_preview() {
2279        let codex_home = unique_temp_dir();
2280        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2281            .await
2282            .expect("state db should initialize");
2283        let thread_id =
2284            ThreadId::from_string("00000000-0000-0000-0000-000000000460").expect("valid thread id");
2285        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2286        metadata.first_user_message = None;
2287        metadata.preview = None;
2288
2289        runtime
2290            .upsert_thread(&metadata)
2291            .await
2292            .expect("initial upsert should succeed");
2293
2294        let empty_updated = runtime
2295            .set_thread_preview_if_empty(thread_id, "  ")
2296            .await
2297            .expect("empty preview update should succeed");
2298        assert!(!empty_updated);
2299        let goal_updated = runtime
2300            .set_thread_preview_if_empty(thread_id, "  goal preview  ")
2301            .await
2302            .expect("goal preview update should succeed");
2303        assert!(goal_updated);
2304        let overwrite_updated = runtime
2305            .set_thread_preview_if_empty(thread_id, "new preview")
2306            .await
2307            .expect("overwrite preview update should succeed");
2308        assert!(!overwrite_updated);
2309
2310        let persisted = runtime
2311            .get_thread(thread_id)
2312            .await
2313            .expect("thread should load")
2314            .expect("thread should exist");
2315        assert_eq!(persisted.preview.as_deref(), Some("goal preview"));
2316    }
2317
2318    #[tokio::test]
2319    async fn update_thread_git_info_preserves_newer_non_git_metadata() {
2320        let codex_home = unique_temp_dir();
2321        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2322            .await
2323            .expect("state db should initialize");
2324        let thread_id =
2325            ThreadId::from_string("00000000-0000-0000-0000-000000000789").expect("valid thread id");
2326        let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2327
2328        runtime
2329            .upsert_thread(&metadata)
2330            .await
2331            .expect("initial upsert should succeed");
2332
2333        let updated_at = datetime_to_epoch_millis(
2334            DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp"),
2335        );
2336        sqlx::query(
2337            "UPDATE threads SET updated_at = ?, updated_at_ms = ?, tokens_used = ?, first_user_message = ?, preview = ? WHERE id = ?",
2338        )
2339        .bind(updated_at / 1000)
2340        .bind(updated_at)
2341        .bind(123_i64)
2342        .bind("newer preview")
2343        .bind("newer preview")
2344        .bind(thread_id.to_string())
2345        .execute(runtime.pool.as_ref())
2346        .await
2347        .expect("concurrent metadata write should succeed");
2348
2349        let updated = runtime
2350            .update_thread_git_info(
2351                thread_id,
2352                Some(Some("abc123")),
2353                Some(Some("feature/branch")),
2354                Some(Some("git@example.com:openai/codex.git")),
2355            )
2356            .await
2357            .expect("git info update should succeed");
2358        assert!(updated, "git info update should touch the thread row");
2359
2360        let persisted = runtime
2361            .get_thread(thread_id)
2362            .await
2363            .expect("thread should load")
2364            .expect("thread should exist");
2365        assert_eq!(persisted.tokens_used, 123);
2366        assert_eq!(
2367            persisted.first_user_message.as_deref(),
2368            Some("newer preview")
2369        );
2370        assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
2371        assert_eq!(datetime_to_epoch_millis(persisted.updated_at), updated_at);
2372        assert_eq!(persisted.git_sha.as_deref(), Some("abc123"));
2373        assert_eq!(persisted.git_branch.as_deref(), Some("feature/branch"));
2374        assert_eq!(
2375            persisted.git_origin_url.as_deref(),
2376            Some("git@example.com:openai/codex.git")
2377        );
2378    }
2379
2380    #[tokio::test]
2381    async fn insert_thread_if_absent_preserves_existing_metadata() {
2382        let codex_home = unique_temp_dir();
2383        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2384            .await
2385            .expect("state db should initialize");
2386        let thread_id =
2387            ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id");
2388
2389        let mut existing = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2390        existing.tokens_used = 123;
2391        existing.first_user_message = Some("newer preview".to_string());
2392        existing.preview = Some("newer preview".to_string());
2393        existing.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_100, 0).expect("timestamp");
2394        runtime
2395            .upsert_thread(&existing)
2396            .await
2397            .expect("initial upsert should succeed");
2398
2399        let mut fallback = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2400        fallback.tokens_used = 0;
2401        fallback.first_user_message = None;
2402        fallback.preview = None;
2403        fallback.updated_at = DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("timestamp");
2404
2405        let inserted = runtime
2406            .insert_thread_if_absent(&fallback)
2407            .await
2408            .expect("insert should succeed");
2409        assert!(!inserted, "existing rows should not be overwritten");
2410
2411        let persisted = runtime
2412            .get_thread(thread_id)
2413            .await
2414            .expect("thread should load")
2415            .expect("thread should exist");
2416        assert_eq!(persisted.tokens_used, 123);
2417        assert_eq!(
2418            persisted.first_user_message.as_deref(),
2419            Some("newer preview")
2420        );
2421        assert_eq!(persisted.preview.as_deref(), Some("newer preview"));
2422        assert_eq!(
2423            datetime_to_epoch_millis(persisted.updated_at),
2424            datetime_to_epoch_millis(existing.updated_at)
2425        );
2426    }
2427
2428    #[tokio::test]
2429    async fn update_thread_git_info_can_clear_fields() {
2430        let codex_home = unique_temp_dir();
2431        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2432            .await
2433            .expect("state db should initialize");
2434        let thread_id =
2435            ThreadId::from_string("00000000-0000-0000-0000-000000000790").expect("valid thread id");
2436        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2437        metadata.git_sha = Some("abc123".to_string());
2438        metadata.git_branch = Some("feature/branch".to_string());
2439        metadata.git_origin_url = Some("git@example.com:openai/codex.git".to_string());
2440
2441        runtime
2442            .upsert_thread(&metadata)
2443            .await
2444            .expect("initial upsert should succeed");
2445
2446        let updated = runtime
2447            .update_thread_git_info(thread_id, Some(None), Some(None), Some(None))
2448            .await
2449            .expect("git info clear should succeed");
2450        assert!(updated, "git info clear should touch the thread row");
2451
2452        let persisted = runtime
2453            .get_thread(thread_id)
2454            .await
2455            .expect("thread should load")
2456            .expect("thread should exist");
2457        assert_eq!(persisted.git_sha, None);
2458        assert_eq!(persisted.git_branch, None);
2459        assert_eq!(persisted.git_origin_url, None);
2460    }
2461
2462    #[tokio::test]
2463    async fn touch_thread_updated_at_updates_only_updated_at() {
2464        let codex_home = unique_temp_dir();
2465        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2466            .await
2467            .expect("state db should initialize");
2468        let thread_id =
2469            ThreadId::from_string("00000000-0000-0000-0000-000000000791").expect("valid thread id");
2470        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2471        metadata.title = "original title".to_string();
2472        metadata.first_user_message = Some("first-user-message".to_string());
2473        metadata.preview = None;
2474
2475        runtime
2476            .upsert_thread(&metadata)
2477            .await
2478            .expect("initial upsert should succeed");
2479
2480        let touched_at = DateTime::<Utc>::from_timestamp(1_700_001_111, 0).expect("timestamp");
2481        let touched = runtime
2482            .touch_thread_updated_at(thread_id, touched_at)
2483            .await
2484            .expect("touch should succeed");
2485        assert!(touched);
2486
2487        let persisted = runtime
2488            .get_thread(thread_id)
2489            .await
2490            .expect("thread should load")
2491            .expect("thread should exist");
2492        assert_eq!(persisted.updated_at, touched_at);
2493        assert_eq!(persisted.title, "original title");
2494        assert_eq!(
2495            persisted.first_user_message.as_deref(),
2496            Some("first-user-message")
2497        );
2498        assert_eq!(persisted.preview.as_deref(), Some("first-user-message"));
2499    }
2500
2501    #[tokio::test]
2502    async fn touch_thread_recency_at_is_monotonic_and_survives_stale_upsert() {
2503        let codex_home = unique_temp_dir();
2504        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2505            .await
2506            .expect("state db should initialize");
2507        let thread_id =
2508            ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id");
2509        let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2510        let original_recency_at = metadata.recency_at;
2511        runtime
2512            .upsert_thread(&metadata)
2513            .await
2514            .expect("initial upsert should succeed");
2515
2516        let touched_at =
2517            DateTime::<Utc>::from_timestamp_millis(1_700_001_111_123).expect("timestamp");
2518        assert!(
2519            runtime
2520                .touch_thread_recency_at(thread_id, touched_at)
2521                .await
2522                .expect("touch should succeed")
2523        );
2524
2525        metadata.updated_at =
2526            DateTime::<Utc>::from_timestamp_millis(1_700_001_222_456).expect("timestamp");
2527        metadata.title = "updated metadata".to_string();
2528        assert_eq!(metadata.recency_at, original_recency_at);
2529        runtime
2530            .upsert_thread(&metadata)
2531            .await
2532            .expect("stale metadata upsert should succeed");
2533
2534        let persisted = runtime
2535            .get_thread(thread_id)
2536            .await
2537            .expect("thread should load")
2538            .expect("thread should exist");
2539        assert_eq!(persisted.recency_at, touched_at);
2540        assert_eq!(persisted.updated_at, metadata.updated_at);
2541        assert_eq!(persisted.title, "updated metadata");
2542
2543        assert!(
2544            runtime
2545                .touch_thread_recency_at(thread_id, original_recency_at)
2546                .await
2547                .expect("older touch should succeed")
2548        );
2549        let persisted = runtime
2550            .get_thread(thread_id)
2551            .await
2552            .expect("thread should load")
2553            .expect("thread should exist");
2554        assert_eq!(
2555            datetime_to_epoch_millis(persisted.recency_at),
2556            datetime_to_epoch_millis(touched_at) + 1
2557        );
2558    }
2559
2560    #[tokio::test]
2561    async fn list_threads_orders_and_pages_by_recency_at() {
2562        let codex_home = unique_temp_dir();
2563        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2564            .await
2565            .expect("state db should initialize");
2566        let first_id =
2567            ThreadId::from_string("00000000-0000-0000-0000-000000000793").expect("valid thread id");
2568        let second_id =
2569            ThreadId::from_string("00000000-0000-0000-0000-000000000794").expect("valid thread id");
2570        let third_id =
2571            ThreadId::from_string("00000000-0000-0000-0000-000000000795").expect("valid thread id");
2572        let recency_at =
2573            DateTime::<Utc>::from_timestamp_millis(1_700_002_000_456).expect("timestamp");
2574
2575        for thread_id in [first_id, second_id, third_id] {
2576            let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2577            metadata.recency_at = recency_at;
2578            runtime
2579                .upsert_thread(&metadata)
2580                .await
2581                .expect("thread insert should succeed");
2582        }
2583        sqlx::query("UPDATE threads SET recency_at = ?, recency_at_ms = ?")
2584            .bind(datetime_to_epoch_seconds(recency_at))
2585            .bind(datetime_to_epoch_millis(recency_at))
2586            .execute(runtime.pool.as_ref())
2587            .await
2588            .expect("recency timestamps should match");
2589
2590        let first_page = runtime
2591            .list_threads(
2592                /*page_size*/ 1,
2593                ThreadFilterOptions {
2594                    archived_only: false,
2595                    allowed_sources: &[],
2596                    model_providers: None,
2597                    cwd_filters: None,
2598                    anchor: None,
2599                    sort_key: SortKey::RecencyAt,
2600                    sort_direction: SortDirection::Desc,
2601                    search_term: None,
2602                },
2603            )
2604            .await
2605            .expect("list should succeed");
2606        assert_eq!(
2607            first_page
2608                .items
2609                .iter()
2610                .map(|item| item.id)
2611                .collect::<Vec<_>>(),
2612            vec![third_id]
2613        );
2614        assert_eq!(
2615            first_page.next_anchor,
2616            Some(Anchor {
2617                ts: recency_at,
2618                id: Some(third_id),
2619            })
2620        );
2621
2622        let second_page = runtime
2623            .list_threads(
2624                /*page_size*/ 1,
2625                ThreadFilterOptions {
2626                    archived_only: false,
2627                    allowed_sources: &[],
2628                    model_providers: None,
2629                    cwd_filters: None,
2630                    anchor: first_page.next_anchor.as_ref(),
2631                    sort_key: SortKey::RecencyAt,
2632                    sort_direction: SortDirection::Desc,
2633                    search_term: None,
2634                },
2635            )
2636            .await
2637            .expect("second list should succeed");
2638        assert_eq!(
2639            second_page
2640                .items
2641                .iter()
2642                .map(|item| item.id)
2643                .collect::<Vec<_>>(),
2644            vec![second_id]
2645        );
2646        assert_eq!(
2647            second_page.next_anchor,
2648            Some(Anchor {
2649                ts: recency_at,
2650                id: Some(second_id),
2651            })
2652        );
2653
2654        let third_page = runtime
2655            .list_threads(
2656                /*page_size*/ 1,
2657                ThreadFilterOptions {
2658                    archived_only: false,
2659                    allowed_sources: &[],
2660                    model_providers: None,
2661                    cwd_filters: None,
2662                    anchor: second_page.next_anchor.as_ref(),
2663                    sort_key: SortKey::RecencyAt,
2664                    sort_direction: SortDirection::Desc,
2665                    search_term: None,
2666                },
2667            )
2668            .await
2669            .expect("third list should succeed");
2670        assert_eq!(
2671            third_page
2672                .items
2673                .iter()
2674                .map(|item| item.id)
2675                .collect::<Vec<_>>(),
2676            vec![first_id]
2677        );
2678        assert_eq!(third_page.next_anchor, None);
2679    }
2680
2681    #[tokio::test]
2682    async fn thread_updated_at_uses_unique_epoch_millis_and_reads_legacy_seconds() {
2683        let codex_home = unique_temp_dir();
2684        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2685            .await
2686            .expect("state db should initialize");
2687        let first_id =
2688            ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
2689        let second_id =
2690            ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
2691        let older_id =
2692            ThreadId::from_string("00000000-0000-0000-0000-000000000903").expect("valid thread id");
2693        let updated_at =
2694            DateTime::<Utc>::from_timestamp_millis(1_700_001_111_123).expect("timestamp millis");
2695        let mut first = test_thread_metadata(&codex_home, first_id, codex_home.clone());
2696        first.updated_at = updated_at;
2697        first.recency_at = updated_at;
2698        let mut second = test_thread_metadata(&codex_home, second_id, codex_home.clone());
2699        second.updated_at = updated_at;
2700        second.recency_at = updated_at;
2701
2702        runtime
2703            .upsert_thread(&first)
2704            .await
2705            .expect("first upsert should succeed");
2706        runtime
2707            .upsert_thread(&second)
2708            .await
2709            .expect("second upsert should succeed");
2710
2711        let first = runtime
2712            .get_thread(first_id)
2713            .await
2714            .expect("thread should load")
2715            .expect("thread should exist");
2716        let second = runtime
2717            .get_thread(second_id)
2718            .await
2719            .expect("thread should load")
2720            .expect("thread should exist");
2721        assert_eq!(
2722            datetime_to_epoch_millis(first.updated_at),
2723            1_700_001_111_123
2724        );
2725        assert_eq!(
2726            datetime_to_epoch_millis(second.updated_at),
2727            1_700_001_111_124
2728        );
2729        assert_eq!(
2730            datetime_to_epoch_millis(first.recency_at),
2731            1_700_001_111_123
2732        );
2733        assert_eq!(
2734            datetime_to_epoch_millis(second.recency_at),
2735            1_700_001_111_124
2736        );
2737        let second_row: (i64, i64, Option<i64>, Option<i64>) = sqlx::query_as(
2738            "SELECT created_at, updated_at, created_at_ms, updated_at_ms FROM threads WHERE id = ?",
2739        )
2740        .bind(second_id.to_string())
2741        .fetch_one(runtime.pool.as_ref())
2742        .await
2743        .expect("thread timestamp row should load");
2744        assert_eq!(
2745            second_row,
2746            (
2747                datetime_to_epoch_seconds(second.created_at),
2748                1_700_001_111,
2749                Some(datetime_to_epoch_millis(second.created_at)),
2750                Some(1_700_001_111_124)
2751            )
2752        );
2753
2754        let older_updated_at =
2755            DateTime::<Utc>::from_timestamp_millis(1_700_001_100_123).expect("timestamp millis");
2756        let mut older = test_thread_metadata(&codex_home, older_id, codex_home.clone());
2757        older.updated_at = older_updated_at;
2758        runtime
2759            .upsert_thread(&older)
2760            .await
2761            .expect("older upsert should succeed");
2762        let older = runtime
2763            .get_thread(older_id)
2764            .await
2765            .expect("thread should load")
2766            .expect("thread should exist");
2767        assert_eq!(
2768            datetime_to_epoch_millis(older.updated_at),
2769            1_700_001_100_123
2770        );
2771
2772        sqlx::query("UPDATE threads SET updated_at = ? WHERE id = ?")
2773            .bind(1_700_001_112_i64)
2774            .bind(first_id.to_string())
2775            .execute(runtime.pool.as_ref())
2776            .await
2777            .expect("legacy timestamp write should succeed");
2778        let legacy = runtime
2779            .get_thread(first_id)
2780            .await
2781            .expect("thread should load")
2782            .expect("thread should exist");
2783        assert_eq!(
2784            datetime_to_epoch_millis(legacy.updated_at),
2785            1_700_001_112_000
2786        );
2787    }
2788
2789    #[tokio::test]
2790    async fn apply_rollout_items_uses_override_updated_at_when_provided() {
2791        let codex_home = unique_temp_dir();
2792        let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2793            .await
2794            .expect("state db should initialize");
2795        let thread_id =
2796            ThreadId::from_string("00000000-0000-0000-0000-000000000792").expect("valid thread id");
2797        let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
2798
2799        runtime
2800            .upsert_thread(&metadata)
2801            .await
2802            .expect("initial upsert should succeed");
2803
2804        let builder = ThreadMetadataBuilder::new(
2805            thread_id,
2806            metadata.rollout_path.clone(),
2807            metadata.created_at,
2808            SessionSource::Cli,
2809        );
2810        let items = vec![RolloutItem::EventMsg(EventMsg::TokenCount(
2811            codex_protocol::protocol::TokenCountEvent {
2812                info: Some(codex_protocol::protocol::TokenUsageInfo {
2813                    total_token_usage: codex_protocol::protocol::TokenUsage {
2814                        input_tokens: 0,
2815                        cached_input_tokens: 0,
2816                        cache_write_input_tokens: 0,
2817                        output_tokens: 0,
2818                        reasoning_output_tokens: 0,
2819                        total_tokens: 321,
2820                    },
2821                    last_token_usage: codex_protocol::protocol::TokenUsage::default(),
2822                    model_context_window: None,
2823                }),
2824                rate_limits: None,
2825            },
2826        ))];
2827        let override_updated_at =
2828            DateTime::<Utc>::from_timestamp(1_700_001_234, 0).expect("timestamp");
2829
2830        runtime
2831            .apply_rollout_items(
2832                &builder,
2833                &items,
2834                /*new_thread_memory_mode*/ None,
2835                Some(override_updated_at),
2836            )
2837            .await
2838            .expect("apply_rollout_items should succeed");
2839
2840        let persisted = runtime
2841            .get_thread(thread_id)
2842            .await
2843            .expect("thread should load")
2844            .expect("thread should exist");
2845        assert_eq!(persisted.tokens_used, 321);
2846        assert_eq!(persisted.updated_at, override_updated_at);
2847    }
2848
2849    #[tokio::test]
2850    async fn thread_spawn_edges_track_directional_status() {
2851        let codex_home = unique_temp_dir();
2852        let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
2853            .await
2854            .expect("state db should initialize");
2855        let parent_thread_id =
2856            ThreadId::from_string("00000000-0000-0000-0000-000000000900").expect("valid thread id");
2857        let child_thread_id =
2858            ThreadId::from_string("00000000-0000-0000-0000-000000000901").expect("valid thread id");
2859        let grandchild_thread_id =
2860            ThreadId::from_string("00000000-0000-0000-0000-000000000902").expect("valid thread id");
2861
2862        runtime
2863            .upsert_thread_spawn_edge(
2864                parent_thread_id,
2865                child_thread_id,
2866                DirectionalThreadSpawnEdgeStatus::Open,
2867            )
2868            .await
2869            .expect("child edge insert should succeed");
2870        runtime
2871            .upsert_thread_spawn_edge(
2872                child_thread_id,
2873                grandchild_thread_id,
2874                DirectionalThreadSpawnEdgeStatus::Open,
2875            )
2876            .await
2877            .expect("grandchild edge insert should succeed");
2878
2879        let children = runtime
2880            .list_thread_spawn_children_with_status(
2881                parent_thread_id,
2882                DirectionalThreadSpawnEdgeStatus::Open,
2883            )
2884            .await
2885            .expect("open child list should load");
2886        assert_eq!(children, vec![child_thread_id]);
2887
2888        let descendants = runtime
2889            .list_thread_spawn_descendants_with_status(
2890                parent_thread_id,
2891                DirectionalThreadSpawnEdgeStatus::Open,
2892            )
2893            .await
2894            .expect("open descendants should load");
2895        assert_eq!(descendants, vec![child_thread_id, grandchild_thread_id]);
2896
2897        runtime
2898            .set_thread_spawn_edge_status(child_thread_id, DirectionalThreadSpawnEdgeStatus::Closed)
2899            .await
2900            .expect("edge close should succeed");
2901
2902        let open_children = runtime
2903            .list_thread_spawn_children_with_status(
2904                parent_thread_id,
2905                DirectionalThreadSpawnEdgeStatus::Open,
2906            )
2907            .await
2908            .expect("open child list should load");
2909        assert_eq!(open_children, Vec::<ThreadId>::new());
2910
2911        let closed_children = runtime
2912            .list_thread_spawn_children_with_status(
2913                parent_thread_id,
2914                DirectionalThreadSpawnEdgeStatus::Closed,
2915            )
2916            .await
2917            .expect("closed child list should load");
2918        assert_eq!(closed_children, vec![child_thread_id]);
2919
2920        let closed_descendants = runtime
2921            .list_thread_spawn_descendants_with_status(
2922                parent_thread_id,
2923                DirectionalThreadSpawnEdgeStatus::Closed,
2924            )
2925            .await
2926            .expect("closed descendants should load");
2927        assert_eq!(closed_descendants, vec![child_thread_id]);
2928
2929        let open_descendants_from_child = runtime
2930            .list_thread_spawn_descendants_with_status(
2931                child_thread_id,
2932                DirectionalThreadSpawnEdgeStatus::Open,
2933            )
2934            .await
2935            .expect("open descendants from child should load");
2936        assert_eq!(open_descendants_from_child, vec![grandchild_thread_id]);
2937
2938        let all_descendants = runtime
2939            .list_thread_spawn_descendants(parent_thread_id)
2940            .await
2941            .expect("all descendants should load");
2942        assert_eq!(all_descendants, vec![child_thread_id, grandchild_thread_id]);
2943    }
2944
2945    #[tokio::test]
2946    async fn thread_spawn_children_without_status_filter_lists_all_statuses() {
2947        let codex_home = unique_temp_dir();
2948        let runtime = StateRuntime::init(codex_home, "test-provider".to_string())
2949            .await
2950            .expect("state db should initialize");
2951        let parent_thread_id =
2952            ThreadId::from_string("00000000-0000-0000-0000-000000000910").expect("valid thread id");
2953        let open_child_thread_id =
2954            ThreadId::from_string("00000000-0000-0000-0000-000000000911").expect("valid thread id");
2955        let closed_child_thread_id =
2956            ThreadId::from_string("00000000-0000-0000-0000-000000000912").expect("valid thread id");
2957        let future_child_thread_id =
2958            ThreadId::from_string("00000000-0000-0000-0000-000000000913").expect("valid thread id");
2959
2960        runtime
2961            .upsert_thread_spawn_edge(
2962                parent_thread_id,
2963                open_child_thread_id,
2964                DirectionalThreadSpawnEdgeStatus::Open,
2965            )
2966            .await
2967            .expect("open child edge insert should succeed");
2968        runtime
2969            .upsert_thread_spawn_edge(
2970                parent_thread_id,
2971                closed_child_thread_id,
2972                DirectionalThreadSpawnEdgeStatus::Closed,
2973            )
2974            .await
2975            .expect("closed child edge insert should succeed");
2976        sqlx::query(
2977            r#"
2978INSERT INTO thread_spawn_edges (
2979    parent_thread_id,
2980    child_thread_id,
2981    status
2982) VALUES (?, ?, ?)
2983            "#,
2984        )
2985        .bind(parent_thread_id.to_string())
2986        .bind(future_child_thread_id.to_string())
2987        .bind("future")
2988        .execute(runtime.pool.as_ref())
2989        .await
2990        .expect("future-status child edge insert should succeed");
2991
2992        let children = runtime
2993            .list_thread_spawn_children(parent_thread_id)
2994            .await
2995            .expect("all children should load");
2996        assert_eq!(
2997            children,
2998            vec![
2999                open_child_thread_id,
3000                closed_child_thread_id,
3001                future_child_thread_id,
3002            ]
3003        );
3004    }
3005}