Add experimental app-server project APIs (#38940)

## What changed

- Add SQLite-backed `project/list`, `project/read`, `project/create`,
  `project/import`, `project/update`, `project/move`, and `project/delete`
  endpoints with ordered roots, metadata, manual positioning, pagination, and
  idempotent creation.
- Add project assignment to thread start, metadata updates, list filtering,
  and fork inheritance. Project deletion clears assignments without deleting
  threads.
- Emit `project/changed` and `thread/project/updated` notifications after
  committed changes, and export the new protocol schemas and TypeScript types.

## Testing

- Cover project lifecycle and ordering, idempotency, atomic imports, cursor and
  filter validation, thread assignment, deletion, and fork inheritance.

GitOrigin-RevId: 47ae621be01ab8ef70a74a629fb29a5c5709ea33
This commit is contained in:
Anton Panasenko authored and copyberry committed 2026-08-17 04:01:37 +00:00
1 parent 1f41cc5d92
commit 3b4569a920
107 files changed
+4210 -52

No files matched your search

@@ -283,6 +283,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: true,
search_term: None,
relation_filter: None,
@@ -172,6 +172,7 @@ pub(super) fn stored_thread_from_rollout_item(
section: item.section,
section_position: None,
section_entered_at: None,
project_id: item.project_id,
cwd: item.cwd.unwrap_or_default(),
cli_version: item.cli_version.unwrap_or_default(),
source,
@@ -111,6 +111,10 @@ pub(super) async fn list_threads(
}
}
if let Some(project_id) = params.project_id.as_ref() {
items.retain(|thread| &thread.project_id == project_id);
}
Ok(ThreadPage { items, next_cursor })
}
@@ -181,6 +185,10 @@ async fn list_section_threads(
model_providers: params.model_providers.as_deref(),
cwd_filters: normalized_cwd_filters.as_deref(),
section: Some(Some(section)),
project_id: params
.project_id
.as_ref()
.map(|project_id| project_id.as_deref()),
anchor: anchor.as_ref(),
sort_key: codex_state::SortKey::SectionPosition,
sort_direction: match params.sort_direction {
@@ -245,7 +253,7 @@ pub(super) async fn list_rollout_threads(
sort_key: codex_rollout::ThreadSortKey,
sort_direction: codex_rollout::SortDirection,
) -> ThreadStoreResult<codex_rollout::ThreadsPage> {
if params.relation_filter.is_some() || params.section.is_some() {
if params.relation_filter.is_some() || params.section.is_some() || params.project_id.is_some() {
let relation_filter = params
.relation_filter
.map(|relation_filter| match relation_filter {
@@ -269,6 +277,7 @@ pub(super) async fn list_rollout_threads(
relation_filter,
params.archived,
params.section.as_ref().map(Option::as_deref),
params.project_id.as_ref().map(Option::as_deref),
params.search_term.as_deref(),
)
.await
@@ -391,6 +400,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
@@ -452,6 +462,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: Some("needle".to_string()),
relation_filter: None,
@@ -525,6 +536,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: Some("canonical".to_string()),
relation_filter: None,
@@ -562,6 +574,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
@@ -579,6 +592,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: true,
search_term: None,
relation_filter: None,
@@ -632,6 +646,7 @@ mod tests {
model_providers: Some(vec!["test-provider".to_string()]),
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
@@ -710,6 +725,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: Some(Some(PINNED_THREAD_SECTION_ID.to_owned())),
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
@@ -763,6 +779,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
+48
View File
@@ -8,6 +8,7 @@ mod model_context;
mod move_thread_to_section;
mod paginated_fork;
mod pending_thread_metadata;
mod projects;
mod read_thread;
mod revert_thread;
mod rollout_migration;
@@ -48,21 +49,27 @@ use tokio::sync::RwLock;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::ArchiveThreadsParams;
use crate::CreateProjectParams;
use crate::CreateThreadParams;
use crate::CreateThreadSectionParams;
use crate::CreatedProject;
use crate::DeleteThreadParams;
use crate::DeleteThreadSectionParams;
use crate::DeleteThreadsParams;
use crate::DeletedProject;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListProjectsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams;
use crate::ListTurnsParams;
use crate::LoadThreadHistoryParams;
use crate::MoveProjectParams;
use crate::MoveThreadToSectionParams;
use crate::PersistContext;
use crate::PrepareForkParams;
use crate::PreparedFork;
use crate::ProjectMoveOutcome;
use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
@@ -71,6 +78,8 @@ use crate::RevertThreadParams;
use crate::SearchThreadOccurrencesParams;
use crate::SearchThreadsParams;
use crate::StoredModelContext;
use crate::StoredProject;
use crate::StoredProjectsPage;
use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::StoredThreadSection;
@@ -84,7 +93,9 @@ use crate::ThreadStoreError;
use crate::ThreadStoreFuture;
use crate::ThreadStoreResult;
use crate::TurnPage;
use crate::UpdateProjectParams;
use crate::UpdateThreadMetadataParams;
use crate::UpdatedProject;
use crate::local::writer_lock::WriterLockCoordinator;
use crate::local::writer_lock::WriterLockGuard;
@@ -535,6 +546,43 @@ impl ThreadStore for LocalThreadStore {
Box::pin(async move { thread_sections::delete_thread_section(self, params).await })
}
fn supports_projects(&self) -> bool {
self.state_db.is_some()
}
fn list_projects(
&self,
params: ListProjectsParams,
) -> ThreadStoreFuture<'_, StoredProjectsPage> {
Box::pin(async move { projects::list_projects(self, params).await })
}
fn read_project(&self, project_id: String) -> ThreadStoreFuture<'_, Option<StoredProject>> {
Box::pin(async move { projects::read_project(self, project_id).await })
}
fn create_project(&self, params: CreateProjectParams) -> ThreadStoreFuture<'_, CreatedProject> {
Box::pin(async move { projects::create_project(self, params).await })
}
fn update_project(
&self,
params: UpdateProjectParams,
) -> ThreadStoreFuture<'_, Option<UpdatedProject>> {
Box::pin(async move { projects::update_project(self, params).await })
}
fn move_project(
&self,
params: MoveProjectParams,
) -> ThreadStoreFuture<'_, Option<ProjectMoveOutcome>> {
Box::pin(async move { projects::move_project(self, params).await })
}
fn delete_project(&self, project_id: String) -> ThreadStoreFuture<'_, Option<DeletedProject>> {
Box::pin(async move { projects::delete_project(self, project_id).await })
}
fn supports_paginated_history_lists(&self) -> bool {
self.state_db.is_some()
}
@@ -30,7 +30,6 @@ async fn pending_thread_metadata_is_consumed_by_first_metadata_update() {
.stage_pending_thread_metadata(thread_id, pending_patch.clone())
.await
.expect("stage pending metadata");
let live_thread = LiveThread::create(store.clone(), create_thread_params(thread_id))
.await
.expect("create live thread");
+182
View File
@@ -0,0 +1,182 @@
use super::LocalThreadStore;
use crate::CreateProjectParams;
use crate::CreatedProject;
use crate::DeletedProject;
use crate::ListProjectsParams;
use crate::MoveProjectParams;
use crate::ProjectMoveOutcome;
use crate::StoredProject;
use crate::StoredProjectRoot;
use crate::StoredProjectsPage;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use crate::UpdateProjectParams;
use crate::UpdatedProject;
pub(super) async fn list_projects(
store: &LocalThreadStore,
params: ListProjectsParams,
) -> ThreadStoreResult<StoredProjectsPage> {
let state = state(store)?;
let has_cursor = params.cursor.is_some();
let page = state
.list_projects(params.cursor.as_deref(), params.limit)
.await
.map_err(|error| {
if has_cursor && error.to_string().starts_with("invalid project cursor:") {
ThreadStoreError::InvalidRequest {
message: error.to_string(),
}
} else {
internal(error)
}
})?;
Ok(StoredProjectsPage {
projects: page.projects.into_iter().map(stored_project).collect(),
next_cursor: page.next_cursor,
})
}
pub(super) async fn read_project(
store: &LocalThreadStore,
project_id: String,
) -> ThreadStoreResult<Option<StoredProject>> {
state(store)?
.get_project(&project_id)
.await
.map(|project| project.map(stored_project))
.map_err(internal)
}
pub(super) async fn create_project(
store: &LocalThreadStore,
params: CreateProjectParams,
) -> ThreadStoreResult<CreatedProject> {
state(store)?
.create_project(
params.name,
params.roots.into_iter().map(state_root).collect(),
params.metadata,
&params.thread_ids,
&params.idempotency_key,
)
.await
.map(|created| CreatedProject {
project: stored_project(created.project),
created: created.created,
})
.map_err(project_idempotency_error)
}
pub(super) async fn update_project(
store: &LocalThreadStore,
params: UpdateProjectParams,
) -> ThreadStoreResult<Option<UpdatedProject>> {
state(store)?
.update_project(
&params.project_id,
params.name,
params
.roots
.map(|roots| roots.into_iter().map(state_root).collect()),
params.metadata,
)
.await
.map(|result| {
result.map(|(project, changed)| UpdatedProject {
project: stored_project(project),
changed,
})
})
.map_err(internal)
}
pub(super) async fn move_project(
store: &LocalThreadStore,
params: MoveProjectParams,
) -> ThreadStoreResult<Option<ProjectMoveOutcome>> {
state(store)?
.move_project(&params.project_id, params.before_project_id.as_deref())
.await
.map(|result| {
result.map(|changed| {
if changed {
ProjectMoveOutcome::Moved
} else {
ProjectMoveOutcome::Unchanged
}
})
})
.map_err(|error| {
let message = error.to_string();
if message.starts_with("before project ")
|| message.starts_with("project ") && message.contains("cannot be moved")
{
ThreadStoreError::InvalidRequest { message }
} else {
internal(message)
}
})
}
pub(super) async fn delete_project(
store: &LocalThreadStore,
project_id: String,
) -> ThreadStoreResult<Option<DeletedProject>> {
state(store)?
.delete_project(&project_id)
.await
.map(|result| {
result.map(
|(affected_active_thread_ids, affected_archived_thread_ids)| DeletedProject {
affected_active_thread_ids,
affected_archived_thread_ids,
},
)
})
.map_err(internal)
}
fn state(store: &LocalThreadStore) -> ThreadStoreResult<&codex_rollout::StateDbHandle> {
store
.state_db
.as_ref()
.ok_or(ThreadStoreError::Unsupported {
operation: "projects",
})
}
fn internal(error: impl std::fmt::Display) -> ThreadStoreError {
ThreadStoreError::Internal {
message: error.to_string(),
}
}
fn project_idempotency_error(error: impl std::fmt::Display) -> ThreadStoreError {
let message = error.to_string();
if message.starts_with("idempotency key refers to deleted project:") {
ThreadStoreError::InvalidRequest { message }
} else {
internal(message)
}
}
fn state_root(root: StoredProjectRoot) -> codex_state::ProjectRoot {
codex_state::ProjectRoot { path: root.path }
}
fn stored_project(project: codex_state::Project) -> StoredProject {
StoredProject {
id: project.id,
name: project.name,
roots: project
.roots
.into_iter()
.map(|root| StoredProjectRoot { path: root.path })
.collect(),
metadata: project.metadata,
position: project.position,
created_at_ms: project.created_at_ms,
updated_at_ms: project.updated_at_ms,
}
}
@@ -65,6 +65,7 @@ pub(super) async fn read_thread(
if thread.name.is_some() {
rollout_thread.name = thread.name;
}
rollout_thread.project_id = thread.project_id;
rollout_thread.git_info = thread.git_info;
rollout_thread.permission_profile = permission_profile_from_metadata_value(
&metadata_sandbox_policy,
@@ -139,6 +140,7 @@ pub(super) async fn read_thread_by_rollout_path(
thread.section = metadata.section;
thread.section_position = metadata.section_position;
thread.section_entered_at = metadata.section_entered_at;
thread.project_id = metadata.project_id;
if !metadata.cwd.as_os_str().is_empty()
&& resolve_requested_rollout_path(store, metadata.rollout_path.clone())
.await
@@ -380,6 +382,7 @@ pub(super) fn stored_thread_from_state_metadata(
section: metadata.section,
section_position: metadata.section_position,
section_entered_at: metadata.section_entered_at,
project_id: metadata.project_id,
cwd: metadata.cwd,
cli_version: metadata.cli_version,
source: parse_session_source(&metadata.source),
@@ -478,6 +481,7 @@ fn stored_thread_from_meta_line(
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
cwd: meta_line.meta.cwd,
cli_version: meta_line.meta.cli_version,
source: meta_line.meta.source,
@@ -100,6 +100,7 @@ pub(super) async fn search_threads(
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: params.archived,
search_term: None,
relation_filter: None,
@@ -169,6 +169,7 @@ async fn split_homes_support_backfill_listing_and_paginated_history() {
search_term: None,
relation_filter: None,
section: None,
project_id: None,
use_state_db_only: true,
})
.await
@@ -46,6 +46,11 @@ pub(super) async fn update_thread_metadata(
merged_patch.merge(patch);
patch = merged_patch;
}
if patch.project_id.is_some() && store.state_db().await.is_none() {
return Err(ThreadStoreError::Unsupported {
operation: "projects",
});
}
if patch.is_empty() {
return read_thread::read_thread(
store,
@@ -312,6 +317,22 @@ async fn apply_metadata_update(
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to read thread metadata for {thread_id}: {err}"),
})?;
let project_id = if existing.is_none()
&& let Some(Some(project_id)) = patch.project_id.as_ref()
&& state_db
.get_project(project_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!(
"failed to read initial thread project for {thread_id}: {err}"
),
})?
.is_none()
{
Some(None)
} else {
patch.project_id.clone()
};
let advance_recency_at = patch.advance_recency_at;
if existing.is_none() && rollout_path.is_none() {
let resolved = if include_archived {
@@ -425,12 +446,49 @@ async fn apply_metadata_update(
metadata.git_branch = branch;
metadata.git_origin_url = origin_url;
}
state_db
.upsert_thread(&metadata)
.await
.map_err(|err| ThreadStoreError::Internal {
if let Some(project_id) = project_id.as_ref() {
metadata.project_id = project_id.clone();
}
let upsert_result = state_db.upsert_thread(&metadata).await;
if existing.is_none()
&& metadata.project_id.is_some()
&& matches!(&upsert_result, Err(err) if err.to_string().contains("FOREIGN KEY constraint failed"))
{
metadata.project_id = None;
state_db.upsert_thread(&metadata).await.map_err(|err| {
ThreadStoreError::Internal {
message: format!("failed to update thread metadata for {thread_id}: {err}"),
}
})?;
} else {
upsert_result.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to update thread metadata for {thread_id}: {err}"),
})?;
}
if existing.is_some()
&& let Some(project_id) = project_id.as_ref()
{
state_db
.set_thread_project(&thread_id.to_string(), project_id.as_deref())
.await
.map_err(|err| {
let message = err.to_string();
if message.contains("project not found") {
ThreadStoreError::InvalidRequest { message }
} else {
ThreadStoreError::Internal {
message: format!(
"failed to update thread project for {thread_id}: {err}"
),
}
}
})?
.ok_or_else(|| ThreadStoreError::Internal {
message: format!(
"thread metadata unavailable before project update: {thread_id}"
),
})?;
}
if let Some(name) = patch.name.as_ref() {
let history_mode = history_mode.ok_or_else(|| ThreadStoreError::Internal {
message: format!(
@@ -595,8 +653,9 @@ fn sqlite_write_failure_should_block(patch: &ThreadMetadataPatch) -> bool {
// transcript-derived metadata, thread names, and memory-mode indexing were log-only. Keep that
// failure isolation so a corrupted optional state DB does not make JSONL transcript durability
// look broken. Explicit git-only updates still require SQLite because partial git patches need
// the existing SQLite value to preserve unspecified fields.
patch.git_info.is_some() && !has_observed_metadata_facts(patch)
// the existing SQLite value to preserve unspecified fields. Project updates always require
// SQLite because assignment only exists in the state database.
patch.project_id.is_some() || (patch.git_info.is_some() && !has_observed_metadata_facts(patch))
}
fn sqlite_write_error_is_best_effort(err: &ThreadStoreError) -> bool {
@@ -926,6 +985,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: Some(Some(codex_state::PINNED_THREAD_SECTION_ID.to_string())),
project_id: None,
archived: false,
search_term: None,
relation_filter: None,
@@ -2090,6 +2150,7 @@ mod tests {
model_providers: Some(Vec::new()),
cwd_filters: Some(vec![workspace]),
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: None,