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 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 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 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 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, None)
138 .await
139 }
140
141 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 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, None)
161 .await
162 }
163
164 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 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 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 #[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 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 false,
394 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 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, None)
409 .await
410 }
411
412 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 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 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 pub async fn upsert_thread(&self, metadata: &crate::ThreadMetadata) -> anyhow::Result<()> {
526 self.upsert_thread_with_creation_memory_mode(metadata, 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 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 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 if candidate.saturating_add(1000) <= current {
749 break candidate;
750 }
751
752 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 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 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 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 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 pub async fn delete_thread(&self, thread_id: ThreadId) -> anyhow::Result<u64> {
1018 self.delete_threads_strict(&[thread_id]).await
1019 }
1020
1021 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 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 (Some(_), _) => OrderByIndex::Disabled,
1150 (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#[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 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 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 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 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 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 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 None,
1839 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 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(1, parent_id, filters(None))
1961 .await
1962 .expect("first page should succeed");
1963 let second_page = runtime
1964 .list_threads_by_parent(
1965 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 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 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 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, None,
2114 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, None,
2185 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 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 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 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 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}