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
2026-08-17 04:01:37 +00:00
committed by copyberry
parent 1f41cc5d92
commit 3b4569a920
107 changed files with 4210 additions and 52 deletions
@@ -220,6 +220,7 @@ fn sample_thread_with_metadata(
ephemeral,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
+1
View File
@@ -592,6 +592,7 @@ fn sample_thread(thread_id: &str) -> Thread {
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
@@ -2551,6 +2551,17 @@
],
"type": "object"
},
"ProjectRoot": {
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
}
},
"required": [
"path"
],
"type": "object"
},
"RealtimeConversationVersion": {
"enum": [
"v1",
@@ -3072,6 +3072,29 @@
}
]
},
"ProjectChangeType": {
"enum": [
"created",
"updated",
"deleted"
],
"type": "string"
},
"ProjectChangedNotification": {
"properties": {
"changeType": {
"$ref": "#/definitions/ProjectChangeType"
},
"projectId": {
"type": "string"
}
},
"required": [
"changeType",
"projectId"
],
"type": "object"
},
"RateLimitReachedType": {
"enum": [
"rate_limit_reached",
@@ -3846,6 +3869,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -3927,6 +3957,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -4886,6 +4917,24 @@
],
"type": "object"
},
"ThreadProjectUpdatedNotification": {
"properties": {
"projectId": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"projectId",
"threadId"
],
"type": "object"
},
"ThreadQueueChangedNotification": {
"properties": {
"threadId": {
@@ -6293,6 +6342,46 @@
"title": "Thread/queue/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"project/changed"
],
"title": "Project/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProjectChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Project/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/project/updated"
],
"title": "Thread/project/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadProjectUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/project/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4597,6 +4597,46 @@
"title": "Thread/queue/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"project/changed"
],
"title": "Project/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ProjectChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Project/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/project/updated"
],
"title": "Thread/project/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadProjectUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/project/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -16010,6 +16050,86 @@
],
"type": "object"
},
"Project": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"metadata": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"name": {
"type": "string"
},
"position": {
"format": "int64",
"type": "integer"
},
"roots": {
"items": {
"$ref": "#/definitions/v2/ProjectRoot"
},
"type": "array"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"id",
"metadata",
"name",
"position",
"roots",
"updatedAt"
],
"type": "object"
},
"ProjectChangeType": {
"enum": [
"created",
"updated",
"deleted"
],
"type": "string"
},
"ProjectChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"changeType": {
"$ref": "#/definitions/v2/ProjectChangeType"
},
"projectId": {
"type": "string"
}
},
"required": [
"changeType",
"projectId"
],
"title": "ProjectChangedNotification",
"type": "object"
},
"ProjectRoot": {
"properties": {
"path": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
},
"required": [
"path"
],
"type": "object"
},
"QueuedSubmission": {
"properties": {
"clientUserMessageId": {
@@ -18891,6 +19011,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -18972,6 +19099,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -20606,6 +20734,26 @@
"title": "ThreadNameUpdatedNotification",
"type": "object"
},
"ThreadProjectUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"projectId": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"projectId",
"threadId"
],
"title": "ThreadProjectUpdatedNotification",
"type": "object"
},
"ThreadQueueChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12300,6 +12300,86 @@
],
"type": "object"
},
"Project": {
"properties": {
"createdAt": {
"format": "int64",
"type": "integer"
},
"id": {
"type": "string"
},
"metadata": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"name": {
"type": "string"
},
"position": {
"format": "int64",
"type": "integer"
},
"roots": {
"items": {
"$ref": "#/definitions/ProjectRoot"
},
"type": "array"
},
"updatedAt": {
"format": "int64",
"type": "integer"
}
},
"required": [
"createdAt",
"id",
"metadata",
"name",
"position",
"roots",
"updatedAt"
],
"type": "object"
},
"ProjectChangeType": {
"enum": [
"created",
"updated",
"deleted"
],
"type": "string"
},
"ProjectChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"changeType": {
"$ref": "#/definitions/ProjectChangeType"
},
"projectId": {
"type": "string"
}
},
"required": [
"changeType",
"projectId"
],
"title": "ProjectChangedNotification",
"type": "object"
},
"ProjectRoot": {
"properties": {
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
}
},
"required": [
"path"
],
"type": "object"
},
"QueuedSubmission": {
"properties": {
"clientUserMessageId": {
@@ -14657,6 +14737,46 @@
"title": "Thread/queue/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"project/changed"
],
"title": "Project/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ProjectChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Project/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/project/updated"
],
"title": "Thread/project/updatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadProjectUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/project/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -16643,6 +16763,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -16724,6 +16851,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -18358,6 +18486,26 @@
"title": "ThreadNameUpdatedNotification",
"type": "object"
},
"ThreadProjectUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"projectId": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"projectId",
"threadId"
],
"title": "ThreadProjectUpdatedNotification",
"type": "object"
},
"ThreadQueueChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ProjectChangeType": {
"enum": [
"created",
"updated",
"deleted"
],
"type": "string"
}
},
"properties": {
"changeType": {
"$ref": "#/definitions/ProjectChangeType"
},
"projectId": {
"type": "string"
}
},
"required": [
"changeType",
"projectId"
],
"title": "ProjectChangedNotification",
"type": "object"
}
@@ -1171,6 +1171,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1252,6 +1259,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"projectId": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
}
},
"required": [
"projectId",
"threadId"
],
"title": "ThreadProjectUpdatedNotification",
"type": "object"
}
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -1171,6 +1171,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1252,6 +1259,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -1171,6 +1171,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1252,6 +1259,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
@@ -962,6 +962,13 @@
"description": "Usually the first user message in the thread, if available.",
"type": "string"
},
"projectId": {
"description": "Canonical project assignment owned by app-server, if any.",
"type": [
"string",
"null"
]
},
"recencyAt": {
"description": "Unix timestamp (in seconds) used for thread recency ordering.",
"format": "int64",
@@ -1043,6 +1050,7 @@
"id",
"modelProvider",
"preview",
"projectId",
"sessionId",
"source",
"status",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ProjectRoot } from "./ProjectRoot";
export type Project = { id: string, name: string, roots: Array<ProjectRoot>, metadata: { [key in string]?: string }, position: number, createdAt: number, updatedAt: number, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ProjectChangeType = "created" | "updated" | "deleted";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ProjectChangeType } from "./ProjectChangeType";
export type ProjectChangedNotification = { projectId: string, changeType: ProjectChangeType, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type ProjectRoot = { path: AbsolutePathBuf, };
@@ -34,6 +34,9 @@ section: ThreadSection | null, /**
* Unix timestamp in seconds when the thread entered its current section.
*/
sectionEnteredAt: number | null, /**
* Canonical project assignment owned by app-server, if any.
*/
projectId: string | null, /**
* Model provider used for this thread (for example, 'openai').
*/
modelProvider: string, /**
@@ -3,10 +3,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams";
export type ThreadMetadataUpdateParams = { threadId: string,
/**
export type ThreadMetadataUpdateParams = {threadId: string, /**
* Patch the stored Git metadata for this thread.
* Omit a field to leave it unchanged, set it to `null` to clear it, or
* provide a string to replace the stored value.
*/
gitInfo?: ThreadMetadataGitInfoUpdateParams | null, };
gitInfo?: ThreadMetadataGitInfoUpdateParams | null};
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadProjectUpdatedNotification = { threadId: string, projectId: string | null, };
@@ -362,6 +362,10 @@ export type { ProcessExitedNotification } from "./ProcessExitedNotification";
export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification";
export type { ProcessOutputStream } from "./ProcessOutputStream";
export type { ProcessTerminalSize } from "./ProcessTerminalSize";
export type { Project } from "./Project";
export type { ProjectChangeType } from "./ProjectChangeType";
export type { ProjectChangedNotification } from "./ProjectChangedNotification";
export type { ProjectRoot } from "./ProjectRoot";
export type { QueuedSubmission } from "./QueuedSubmission";
export type { RateLimitReachedType } from "./RateLimitReachedType";
export type { RateLimitResetCredit } from "./RateLimitResetCredit";
@@ -463,6 +467,7 @@ export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoU
export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams";
export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse";
export type { ThreadNameUpdatedNotification } from "./ThreadNameUpdatedNotification";
export type { ThreadProjectUpdatedNotification } from "./ThreadProjectUpdatedNotification";
export type { ThreadQueueChangedNotification } from "./ThreadQueueChangedNotification";
export type { ThreadReadParams } from "./ThreadReadParams";
export type { ThreadReadResponse } from "./ThreadReadResponse";
@@ -612,6 +612,7 @@ client_request_definitions! {
},
ThreadMetadataUpdate => "thread/metadata/update" {
params: v2::ThreadMetadataUpdateParams,
inspect_params: true,
serialization: thread_id(params.thread_id),
response: v2::ThreadMetadataUpdateResponse,
},
@@ -694,6 +695,48 @@ client_request_definitions! {
serialization: None,
response: v2::ThreadListResponse,
},
#[experimental("project/list")]
ProjectList => "project/list" {
params: v2::ProjectListParams,
serialization: global_shared_read("projects"),
response: v2::ProjectListResponse,
},
#[experimental("project/read")]
ProjectRead => "project/read" {
params: v2::ProjectReadParams,
serialization: global_shared_read("projects"),
response: v2::ProjectReadResponse,
},
#[experimental("project/create")]
ProjectCreate => "project/create" {
params: v2::ProjectCreateParams,
serialization: global("projects"),
response: v2::ProjectCreateResponse,
},
#[experimental("project/import")]
ProjectImport => "project/import" {
params: v2::ProjectImportParams,
serialization: global("projects"),
response: v2::ProjectImportResponse,
},
#[experimental("project/update")]
ProjectUpdate => "project/update" {
params: v2::ProjectUpdateParams,
serialization: global("projects"),
response: v2::ProjectUpdateResponse,
},
#[experimental("project/move")]
ProjectMove => "project/move" {
params: v2::ProjectMoveParams,
serialization: global("projects"),
response: v2::ProjectMoveResponse,
},
#[experimental("project/delete")]
ProjectDelete => "project/delete" {
params: v2::ProjectDeleteParams,
serialization: global("projects"),
response: v2::ProjectDeleteResponse,
},
ThreadSectionList => "threadSection/list" {
params: v2::ThreadSectionListParams,
serialization: global_shared_read("thread-sections"),
@@ -1761,6 +1804,10 @@ server_notification_definitions! {
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
#[experimental("thread/queue/changed")]
ThreadQueueChanged => "thread/queue/changed" (v2::ThreadQueueChangedNotification),
#[experimental("project/changed")]
ProjectChanged => "project/changed" (v2::ProjectChangedNotification),
#[experimental("thread/project/updated")]
ThreadProjectUpdated => "thread/project/updated" (v2::ThreadProjectUpdatedNotification),
#[experimental("thread/environment/connected")]
EnvironmentConnected => "thread/environment/connected" (v2::EnvironmentConnectionNotification),
#[experimental("thread/environment/disconnected")]
@@ -2992,6 +3039,7 @@ mod tests {
ephemeral: true,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
@@ -3046,6 +3094,7 @@ mod tests {
"ephemeral": true,
"section": null,
"sectionEnteredAt": null,
"projectId": null,
"historyMode": "legacy",
"modelProvider": "openai",
"createdAt": 1,
@@ -21,6 +21,7 @@ mod permissions;
mod plugin;
mod plugin_search;
mod process;
mod project;
mod realtime;
mod remote_control;
mod review;
@@ -51,6 +52,7 @@ pub use permissions::*;
pub use plugin::*;
pub use plugin_search::*;
pub use process::*;
pub use project::*;
pub use realtime::*;
pub use remote_control::*;
pub use review::*;
@@ -0,0 +1,176 @@
use std::collections::BTreeMap;
use crate::JsonSchema;
use crate::TS;
use codex_experimental_api_macros::ExperimentalApi;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectRoot {
pub path: AbsolutePathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct Project {
pub id: String,
pub name: String,
pub roots: Vec<ProjectRoot>,
pub metadata: BTreeMap<String, String>,
#[ts(type = "number")]
pub position: i64,
#[ts(type = "number")]
pub created_at: i64,
#[ts(type = "number")]
pub updated_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectListParams {
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectListResponse {
pub data: Vec<Project>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectReadParams {
pub project_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectReadResponse {
pub project: Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectCreateParams {
pub name: String,
pub roots: Vec<ProjectRoot>,
#[ts(optional = nullable)]
pub metadata: Option<BTreeMap<String, String>>,
pub idempotency_key: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectCreateResponse {
pub project: Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectImportParams {
pub name: String,
pub roots: Vec<ProjectRoot>,
#[ts(optional = nullable)]
pub metadata: Option<BTreeMap<String, String>>,
#[ts(optional = nullable)]
pub threads: Option<Vec<String>>,
pub idempotency_key: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectImportResponse {
pub project: Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectUpdateParams {
pub project_id: String,
#[ts(optional = nullable)]
pub name: Option<String>,
#[ts(optional = nullable)]
pub roots: Option<Vec<ProjectRoot>>,
#[ts(optional = nullable)]
pub metadata: Option<BTreeMap<String, String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectUpdateResponse {
pub project: Project,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectMoveParams {
pub project_id: String,
#[ts(optional = nullable)]
pub before_project_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectMoveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectDeleteParams {
pub project_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectDeleteResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ProjectChangeType {
Created,
Updated,
Deleted,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ProjectChangedNotification {
pub project_id: String,
pub change_type: ProjectChangeType,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadProjectUpdatedNotification {
pub thread_id: String,
#[schemars(
required,
schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
)]
pub project_id: Option<String>,
}
@@ -258,6 +258,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
appearance: None,
}),
section_entered_at: Some(1),
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 1,
@@ -314,10 +315,12 @@ fn thread_resume_response_round_trips_initial_turns_page() {
.expect("serialized thread should be an object");
legacy_thread_fields.remove("section");
legacy_thread_fields.remove("sectionEnteredAt");
legacy_thread_fields.remove("projectId");
let legacy_thread =
serde_json::from_value::<Thread>(legacy_thread).expect("deserialize legacy thread");
assert_eq!(legacy_thread.section, None);
assert_eq!(legacy_thread.section_entered_at, None);
assert_eq!(legacy_thread.project_id, None);
assert_eq!(
value.get("initialTurnsPage"),
@@ -118,6 +118,11 @@ pub struct ThreadStartParams {
/// Optional client-supplied analytics source classification for this thread.
#[ts(optional = nullable)]
pub thread_source: Option<ThreadSource>,
/// Optional project identity for this new thread. Durable threads persist
/// the assignment; ephemeral threads expose it only in live responses.
#[experimental("thread/start.projectId")]
#[ts(optional = nullable)]
pub project_id: Option<String>,
/// Optional sticky environments for this thread.
///
/// Omitted selects the default environment when environment access is
@@ -858,7 +863,7 @@ pub struct ThreadGoalClearResponse {
pub cleared: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct QueuedSubmission {
@@ -965,11 +970,16 @@ pub struct ThreadQueueStartResponse {
pub turn: Turn,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadMetadataUpdateParams {
pub thread_id: String,
/// Omit to leave the project unchanged, use an empty string to clear it,
/// or provide an existing project ID to assign it.
#[experimental("thread/metadata/update.projectId")]
#[ts(optional = nullable)]
pub project_id: Option<String>,
/// Patch the stored Git metadata for this thread.
/// Omit a field to leave it unchanged, set it to `null` to clear it, or
/// provide a string to replace the stored value.
@@ -1381,6 +1391,17 @@ pub struct ThreadListParams {
)]
#[ts(optional = nullable, type = "string | null")]
pub section_id: Option<Option<String>>,
/// Omit to include every project, set to null for unassigned threads,
/// or provide a project ID to return only threads in that project.
#[experimental("thread/list.projectId")]
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option"
)]
#[ts(optional = nullable, type = "string | null")]
pub project_id: Option<Option<String>>,
/// Optional cwd filter or filters; when set, only threads whose session cwd
/// exactly matches one of these paths are returned.
#[ts(optional = nullable, type = "string | Array<string> | null")]
@@ -190,7 +190,7 @@ pub struct ThreadSectionAppearance {
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct Thread {
@@ -216,6 +216,12 @@ pub struct Thread {
#[serde(default)]
#[ts(type = "number | null")]
pub section_entered_at: Option<i64>,
/// Canonical project assignment owned by app-server, if any.
#[schemars(
required,
schema_with = "crate::protocol::serde_helpers::nullable_string_schema"
)]
pub project_id: Option<String>,
/// Persisted thread history contract selected when this thread was created.
#[experimental("thread.historyMode")]
#[serde(default)]
@@ -262,6 +268,82 @@ pub struct Thread {
pub turns: Vec<Turn>,
}
// TODO: Remove this compatibility decoder after app-server versions that omitted
// `projectId` have aged out of the supported TUI -> remote app-server version-skew window.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ThreadCompatibility {
id: String,
extra: Option<ThreadExtra>,
session_id: String,
forked_from_id: Option<String>,
parent_thread_id: Option<String>,
preview: String,
ephemeral: bool,
#[serde(default)]
section: Option<ThreadSection>,
#[serde(default)]
section_entered_at: Option<i64>,
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
history_mode: ThreadHistoryMode,
model_provider: String,
created_at: i64,
updated_at: i64,
recency_at: Option<i64>,
status: ThreadStatus,
path: Option<PathBuf>,
cwd: AbsolutePathBuf,
cli_version: String,
source: SessionSource,
can_accept_direct_input: Option<bool>,
thread_source: Option<ThreadSource>,
agent_nickname: Option<String>,
agent_role: Option<String>,
git_info: Option<GitInfo>,
name: Option<String>,
turns: Vec<Turn>,
}
impl<'de> Deserialize<'de> for Thread {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let thread = ThreadCompatibility::deserialize(deserializer)?;
Ok(Self {
id: thread.id,
extra: thread.extra,
session_id: thread.session_id,
forked_from_id: thread.forked_from_id,
parent_thread_id: thread.parent_thread_id,
preview: thread.preview,
ephemeral: thread.ephemeral,
section: thread.section,
section_entered_at: thread.section_entered_at,
project_id: thread.project_id,
history_mode: thread.history_mode,
model_provider: thread.model_provider,
created_at: thread.created_at,
updated_at: thread.updated_at,
recency_at: thread.recency_at,
status: thread.status,
path: thread.path,
cwd: thread.cwd,
cli_version: thread.cli_version,
source: thread.source,
can_accept_direct_input: thread.can_accept_direct_input,
thread_source: thread.thread_source,
agent_nickname: thread.agent_nickname,
agent_role: thread.agent_role,
git_info: thread.git_info,
name: thread.name,
turns: thread.turns,
})
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1309,6 +1309,7 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
parent_thread_id: None,
ancestor_thread_id: None,
cwd: None,
+5 -3
View File
@@ -161,11 +161,13 @@ Example with notification opt-out:
## API Overview
- `server/diagnostics` — experimental; read process-local memory measurements and registered diagnostic gauges.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. Experimental `historyMode: "paginated"` selects projection-backed durable history. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the runtime workspace roots used when app-server creates default environment selections; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd` and optional environment-native `runtimeWorkspaceRoots`. Explicit environments ignore the top-level roots; omitted per-environment roots default to that environment's `cwd`, while an empty list explicitly selects no roots. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. Experimental `projectId` assigns a durable thread to an existing project; ephemeral threads expose the same project identity in live responses without creating a stored/listable assignment. Experimental `historyMode: "paginated"` selects projection-backed durable history. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `allowProviderModelFallback` lets providers backed by an authoritative static model catalog replace an unavailable requested `model` with the catalog default; dynamic or cached catalogs preserve the requested model. Experimental `runtimeWorkspaceRoots` supplies the runtime workspace roots used when app-server creates default environment selections; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Deprecated experimental `multiAgentMode` is ignored; use Ultra reasoning effort for proactive multi-agent behavior. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd` and optional environment-native `runtimeWorkspaceRoots`. Explicit environments ignore the top-level roots; omitted per-environment roots default to that environment's `cwd`, while an empty list explicitly selects no roots. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots using environment-native absolute paths. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are started in that environment, and HTTP MCP connections use that environment's HTTP client.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; pass an optional `lastTurnId` to copy history only through that turn, inclusive, and drop later turns from the fork. An in-progress `lastTurnId` boundary is rejected. Experimental `beforeTurnId` instead copies history strictly before the referenced turn, including when that turn is in progress, and cannot be combined with `lastTurnId`. If both boundaries are null while the source thread is mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately, or `deferGoalContinuation: true` to carry the source thread's current goal into the fork and run an explicit turn before automatic continuation resumes. Deferred goal continuation is persisted until that turn starts and cannot be combined with `ephemeral: true`. Accepts the same permission override rules as `thread/start`.
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their deprecated experimental `multiAgentMode` field, and the corresponding thread setting, always report `explicitRequestOnly`; Ultra reasoning effort is the source of proactive multi-agent behavior.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Set `sortKey` to `"section_position"` when listing a section in its persisted manual order. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `sectionId`, `cwd`, and `searchTerm` filters. Experimental `projectId` filters one project, while `null` selects unassigned threads. Set `sortKey` to `"section_position"` when listing a section in its persisted manual order. Experimental clients can use `parentThreadId` for direct spawned children or `ancestorThreadId` for spawned descendants at any depth; the two filters are mutually exclusive. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
- `project/list`, `project/read`, `project/create`, `project/import`, `project/update`, `project/move`, and `project/delete` — experimental SQLite-backed project APIs. Projects have canonical server-generated IDs, persisted manual positions, ordered absolute roots, and an opaque string metadata bag. `project/move` places a project before another project or appends it when `beforeProjectId` is `null`. Create and import require an opaque `idempotencyKey`; clients should generate a UUID for ordinary creates and may use a stable namespaced legacy ID for migration. Reusing a key returns the original project without emitting notifications or repeating thread assignments, and keys remain reserved after deletion. Import can atomically assign existing thread IDs. Delete clears assignments but never deletes threads, directories, or files.
- `project/changed` and `thread/project/updated` — experimental notifications emitted after committed project or assignment changes. Reconnect with `project/list` and `thread/list` to recover authoritative state.
- `threadSection/list` — page through independently persisted thread sections, including their display names and optional `appearance` (`icon` and `color`).
- `threadSection/create` — create a durable custom section with a server-generated UUID, nonempty display name, and optional `appearance`; returns its `section`.
- `threadSection/update` — rename an existing custom section and optionally replace its `appearance`; omit appearance to preserve it or pass `null` to clear it. The built-in pinned section cannot be updated.
@@ -175,7 +177,7 @@ Example with notification opt-out:
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
- `thread/items/list` — experimental; page through persisted thread items without resuming the thread. Pass `turnId` to restrict results to one turn, or omit it to page items across the thread. The active thread store must support item pagination.
- `thread/searchOccurrences` — experimental; find literal, case-insensitive matches in visible user messages and summary-selected final assistant messages within one paginated thread.
- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/metadata/update` — patch stored thread metadata in sqlite; supports updating persisted `gitInfo` fields and experimental `projectId`, then returns the refreshed `thread`. Omit `projectId` to preserve assignment and pass an empty string to clear it.
- `thread/section/move` — atomically move a thread into the section identified by `sectionId`, before another thread or at the end when `beforeThreadId` is `null`. Reordering within the same section preserves `sectionEnteredAt`; entering a different section resets it. Set `sectionId` to `null` to remove the thread from its section. Returns `{}` on success.
- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; deprecated `multiAgentMode` is ignored, while Ultra reasoning effort enables proactive multi-agent behavior; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
@@ -2227,6 +2227,7 @@ mod tests {
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
cwd: test_path_buf("/tmp").abs().into(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -35,6 +35,7 @@ use crate::request_processors::MarketplaceRequestProcessor;
use crate::request_processors::McpRequestProcessor;
use crate::request_processors::PluginRequestProcessor;
use crate::request_processors::ProcessExecRequestProcessor;
use crate::request_processors::ProjectRequestProcessor;
use crate::request_processors::RemoteControlRequestProcessor;
use crate::request_processors::SearchRequestProcessor;
use crate::request_processors::ThreadGoalRequestProcessor;
@@ -147,6 +148,7 @@ pub(crate) struct MessageProcessor {
marketplace_processor: MarketplaceRequestProcessor,
mcp_processor: McpRequestProcessor,
plugin_processor: PluginRequestProcessor,
project_processor: ProjectRequestProcessor,
remote_control_processor: RemoteControlRequestProcessor,
search_processor: SearchRequestProcessor,
thread_goal_processor: ThreadGoalRequestProcessor,
@@ -475,6 +477,11 @@ impl MessageProcessor {
outgoing.clone(),
queue_service,
);
let project_processor = ProjectRequestProcessor::new(
Arc::clone(&thread_store),
outgoing.clone(),
Arc::clone(&thread_list_state_permit),
);
let thread_processor = ThreadRequestProcessor::new(
auth_manager.clone(),
Arc::clone(&thread_manager),
@@ -562,6 +569,7 @@ impl MessageProcessor {
marketplace_processor,
mcp_processor,
plugin_processor,
project_processor,
remote_control_processor,
search_processor,
thread_goal_processor,
@@ -1268,6 +1276,27 @@ impl MessageProcessor {
ClientRequest::ThreadList { params, .. } => {
self.thread_processor.thread_list(params).await
}
ClientRequest::ProjectList { params, .. } => {
self.project_processor.project_list(params).await
}
ClientRequest::ProjectRead { params, .. } => {
self.project_processor.project_read(params).await
}
ClientRequest::ProjectCreate { params, .. } => {
self.project_processor.project_create(params).await
}
ClientRequest::ProjectImport { params, .. } => {
self.project_processor.project_import(params).await
}
ClientRequest::ProjectUpdate { params, .. } => {
self.project_processor.project_update(params).await
}
ClientRequest::ProjectMove { params, .. } => {
self.project_processor.project_move(params).await
}
ClientRequest::ProjectDelete { params, .. } => {
self.project_processor.project_delete(params).await
}
ClientRequest::ThreadSearch { params, .. } => {
self.thread_processor.thread_search(params).await
}
@@ -244,6 +244,7 @@ use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadProjectUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
@@ -475,6 +476,7 @@ use codex_state::ThreadMetadata;
use codex_state::log_db::LogDbLayer;
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
use codex_thread_store::ArchiveThreadsParams as StoreArchiveThreadsParams;
use codex_thread_store::ClearableField as StoreClearableField;
use codex_thread_store::DeleteThreadsParams as StoreDeleteThreadsParams;
use codex_thread_store::GitInfoPatch as StoreGitInfoPatch;
use codex_thread_store::ItemSortKey as StoreItemSortKey;
@@ -545,6 +547,7 @@ mod marketplace_processor;
mod mcp_processor;
mod plugins;
mod process_exec_processor;
mod projects;
mod remote_control_processor;
mod search;
mod thread_enrichment;
@@ -571,6 +574,7 @@ pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use process_exec_processor::ProcessExecRequestProcessor;
pub(crate) use projects::ProjectRequestProcessor;
pub(crate) use remote_control_processor::RemoteControlRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
@@ -0,0 +1,378 @@
use std::collections::HashSet;
use std::sync::Arc;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::Project;
use codex_app_server_protocol::ProjectChangeType;
use codex_app_server_protocol::ProjectChangedNotification;
use codex_app_server_protocol::ProjectCreateParams;
use codex_app_server_protocol::ProjectCreateResponse;
use codex_app_server_protocol::ProjectDeleteParams;
use codex_app_server_protocol::ProjectDeleteResponse;
use codex_app_server_protocol::ProjectImportParams;
use codex_app_server_protocol::ProjectImportResponse;
use codex_app_server_protocol::ProjectListParams;
use codex_app_server_protocol::ProjectListResponse;
use codex_app_server_protocol::ProjectMoveParams;
use codex_app_server_protocol::ProjectMoveResponse;
use codex_app_server_protocol::ProjectReadParams;
use codex_app_server_protocol::ProjectReadResponse;
use codex_app_server_protocol::ProjectRoot;
use codex_app_server_protocol::ProjectUpdateParams;
use codex_app_server_protocol::ProjectUpdateResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadProjectUpdatedNotification;
use codex_thread_store::CreateProjectParams as StoreCreateProjectParams;
use codex_thread_store::ListProjectsParams as StoreListProjectsParams;
use codex_thread_store::MoveProjectParams as StoreMoveProjectParams;
use codex_thread_store::ProjectMoveOutcome;
use codex_thread_store::StoredProject;
use codex_thread_store::StoredProjectRoot;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateProjectParams as StoreUpdateProjectParams;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
use super::thread_processor::THREAD_LIST_DEFAULT_LIMIT;
use super::thread_processor::THREAD_LIST_MAX_LIMIT;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
use crate::error_code::method_not_found;
use crate::outgoing_message::OutgoingMessageSender;
#[derive(Clone)]
pub(crate) struct ProjectRequestProcessor {
thread_store: Arc<dyn ThreadStore>,
outgoing: Arc<OutgoingMessageSender>,
thread_list_state_permit: Arc<Semaphore>,
}
impl ProjectRequestProcessor {
pub(crate) fn new(
thread_store: Arc<dyn ThreadStore>,
outgoing: Arc<OutgoingMessageSender>,
thread_list_state_permit: Arc<Semaphore>,
) -> Self {
Self {
thread_store,
outgoing,
thread_list_state_permit,
}
}
pub(crate) async fn project_list(
&self,
params: ProjectListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let page = self
.thread_store
.list_projects(StoreListProjectsParams {
cursor: params.cursor,
limit: params
.limit
.map(|limit| limit as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, THREAD_LIST_MAX_LIMIT),
})
.await
.map_err(|error| project_store_error("project/list", error))?;
Ok(Some(
ProjectListResponse {
data: page
.projects
.into_iter()
.map(api_project)
.collect::<Result<_, _>>()?,
next_cursor: page.next_cursor,
}
.into(),
))
}
pub(crate) async fn project_read(
&self,
params: ProjectReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let project = self
.thread_store
.read_project(params.project_id.clone())
.await
.map_err(|error| project_store_error("project/read", error))?
.ok_or_else(|| invalid_params(format!("project not found: {}", params.project_id)))?;
Ok(Some(
ProjectReadResponse {
project: api_project(project)?,
}
.into(),
))
}
pub(crate) async fn project_create(
&self,
params: ProjectCreateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let (project, created, _) = self
.create_project(
params.name,
params.roots,
params.metadata,
Vec::new(),
params.idempotency_key,
"project/create",
)
.await?;
if created {
self.notify_project_changed(&project.id, ProjectChangeType::Created)
.await;
}
Ok(Some(ProjectCreateResponse { project }.into()))
}
pub(crate) async fn project_import(
&self,
params: ProjectImportParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let (project, created, thread_ids) = self
.create_project(
params.name,
params.roots,
params.metadata,
params.threads.unwrap_or_default(),
params.idempotency_key,
"project/import",
)
.await?;
if created {
self.notify_project_changed(&project.id, ProjectChangeType::Created)
.await;
self.notify_thread_projects(thread_ids, Some(project.id.clone()))
.await;
}
Ok(Some(ProjectImportResponse { project }.into()))
}
pub(crate) async fn project_update(
&self,
params: ProjectUpdateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let name = params.name.map(validate_name).transpose()?;
let roots = params.roots.map(validate_roots).transpose()?;
let updated = self
.thread_store
.update_project(StoreUpdateProjectParams {
project_id: params.project_id.clone(),
name,
roots,
metadata: params.metadata,
})
.await
.map_err(|error| project_store_error("project/update", error))?
.ok_or_else(|| invalid_params(format!("project not found: {}", params.project_id)))?;
let project = api_project(updated.project)?;
if updated.changed {
self.notify_project_changed(&project.id, ProjectChangeType::Updated)
.await;
}
Ok(Some(ProjectUpdateResponse { project }.into()))
}
pub(crate) async fn project_move(
&self,
params: ProjectMoveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let project_id = params.project_id;
let outcome = self
.thread_store
.move_project(StoreMoveProjectParams {
project_id: project_id.clone(),
before_project_id: params.before_project_id,
})
.await
.map_err(|error| project_store_error("project/move", error))?
.ok_or_else(|| invalid_params(format!("project not found: {project_id}")))?;
if outcome == ProjectMoveOutcome::Moved {
self.notify_project_changed(&project_id, ProjectChangeType::Updated)
.await;
}
Ok(Some(ProjectMoveResponse {}.into()))
}
pub(crate) async fn project_delete(
&self,
params: ProjectDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let deleted = self
.thread_store
.delete_project(params.project_id.clone())
.await
.map_err(|error| project_store_error("project/delete", error))?
.ok_or_else(|| invalid_params(format!("project not found: {}", params.project_id)))?;
self.notify_project_changed(&params.project_id, ProjectChangeType::Deleted)
.await;
self.notify_thread_projects(deleted.affected_active_thread_ids, /*project_id*/ None)
.await;
Ok(Some(ProjectDeleteResponse {}.into()))
}
async fn create_project(
&self,
name: String,
roots: Vec<ProjectRoot>,
metadata: Option<std::collections::BTreeMap<String, String>>,
thread_ids: Vec<String>,
idempotency_key: String,
operation: &'static str,
) -> Result<(Project, bool, Vec<String>), JSONRPCErrorError> {
let idempotency_key = validate_idempotency_key(idempotency_key)?;
let thread_ids = validate_thread_ids(thread_ids)?;
let created = self
.thread_store
.create_project(StoreCreateProjectParams {
name: validate_name(name)?,
roots: validate_roots(roots)?,
metadata: metadata.unwrap_or_default(),
thread_ids: thread_ids.clone(),
idempotency_key,
})
.await
.map_err(|error| project_store_error(operation, error))?;
Ok((api_project(created.project)?, created.created, thread_ids))
}
async fn notify_project_changed(&self, project_id: &str, change_type: ProjectChangeType) {
self.outgoing
.send_server_notification(ServerNotification::ProjectChanged(
ProjectChangedNotification {
project_id: project_id.to_string(),
change_type,
},
))
.await;
}
async fn notify_thread_projects(&self, thread_ids: Vec<String>, project_id: Option<String>) {
for thread_id in thread_ids {
self.outgoing
.send_server_notification(ServerNotification::ThreadProjectUpdated(
ThreadProjectUpdatedNotification {
thread_id,
project_id: project_id.clone(),
},
))
.await;
}
}
async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
.acquire()
.await
.map_err(|err| {
internal_error(format!("failed to acquire thread list state permit: {err}"))
})
}
}
fn validate_name(name: String) -> Result<String, JSONRPCErrorError> {
let name = name.trim().to_string();
if name.is_empty() {
return Err(invalid_params("project name must not be empty"));
}
Ok(name)
}
fn validate_idempotency_key(key: String) -> Result<String, JSONRPCErrorError> {
if key.trim().is_empty() {
return Err(invalid_params("idempotencyKey must not be empty"));
}
if key.len() > 512 {
return Err(invalid_params("idempotencyKey must be at most 512 bytes"));
}
Ok(key)
}
fn validate_roots(roots: Vec<ProjectRoot>) -> Result<Vec<StoredProjectRoot>, JSONRPCErrorError> {
let mut logical = HashSet::new();
let mut canonical = HashSet::new();
roots
.into_iter()
.map(|root| {
let path = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path_checked(
root.path.into_path_buf(),
)
.map_err(|error| invalid_params(format!("invalid project root: {error}")))?
.into_path_buf();
if !logical.insert(path.clone()) {
return Err(invalid_params(format!(
"duplicate project root: {}",
path.display()
)));
}
if let Ok(resolved) = std::fs::canonicalize(&path)
&& !canonical.insert(resolved)
{
return Err(invalid_params(format!(
"duplicate resolved project root: {}",
path.display()
)));
}
Ok(StoredProjectRoot {
path: path.display().to_string(),
})
})
.collect()
}
fn validate_thread_ids(thread_ids: Vec<String>) -> Result<Vec<String>, JSONRPCErrorError> {
let mut seen = HashSet::new();
for thread_id in &thread_ids {
if !seen.insert(thread_id.clone()) {
return Err(invalid_params(format!("duplicate thread id: {thread_id}")));
}
}
Ok(thread_ids)
}
fn api_project(project: StoredProject) -> Result<Project, JSONRPCErrorError> {
Ok(Project {
id: project.id,
name: project.name,
roots: project
.roots
.into_iter()
.map(|root| {
Ok(ProjectRoot {
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(root.path)
.map_err(|error| {
internal_error(format!("stored project root is not absolute: {error}"))
})?,
})
})
.collect::<Result<_, JSONRPCErrorError>>()?,
metadata: project.metadata,
position: project.position,
created_at: project.created_at_ms / 1000,
updated_at: project.updated_at_ms / 1000,
})
}
fn project_store_error(operation: &'static str, error: ThreadStoreError) -> JSONRPCErrorError {
match error {
ThreadStoreError::Unsupported { .. } => {
method_not_found(format!("{operation} is unavailable without sqlite state"))
}
ThreadStoreError::InvalidRequest { message } => invalid_params(message),
ThreadStoreError::Internal { message }
if message.contains("thread not found") || message.contains("project not found") =>
{
invalid_params(message)
}
error => internal_error(format!("failed to run {operation}: {error}")),
}
}
@@ -25,11 +25,53 @@ const CODEX_TUI_CLIENT_NAME: &str = "codex-tui";
const THREAD_ROLLBACK_DEPRECATION_SUMMARY: &str =
"thread/rollback is deprecated and will be removed soon";
async fn stage_pending_project_metadata(
thread_manager: &ThreadManager,
thread_store: &dyn ThreadStore,
project_id: Option<&str>,
operation: &'static str,
) -> Result<Option<ThreadId>, JSONRPCErrorError> {
let Some(project_id) = project_id else {
return Ok(None);
};
let thread_id = thread_manager.reserve_thread_id();
thread_store
.stage_pending_thread_metadata(
thread_id,
StoreThreadMetadataPatch {
project_id: Some(Some(project_id.to_string())),
..Default::default()
},
)
.await
.map_err(|error| match error {
ThreadStoreError::Unsupported { .. } => {
method_not_found(format!("{operation} is unavailable without sqlite state"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
error => internal_error(format!("failed to stage {operation} metadata: {error}")),
})?;
Ok(Some(thread_id))
}
async fn remove_pending_project_metadata(
thread_store: &dyn ThreadStore,
thread_id: Option<ThreadId>,
) {
let Some(thread_id) = thread_id else {
return;
};
if let Err(error) = thread_store.remove_pending_thread_metadata(thread_id).await {
warn!("failed to remove staged project metadata for {thread_id}: {error}");
}
}
struct ThreadListFilters {
model_providers: Option<Vec<String>>,
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: bool,
section_id: Option<Option<String>>,
project_id: StoreClearableField<String>,
cwd_filters: Option<Vec<PathBuf>>,
search_term: Option<String>,
use_state_db_only: bool,
@@ -1083,6 +1125,7 @@ impl ThreadRequestProcessor {
history_mode,
session_start_source,
thread_source,
project_id,
environments,
} = params;
if matches!(
@@ -1099,6 +1142,24 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
if let Some(project_id) = project_id.as_ref() {
if project_id.is_empty() {
return Err(invalid_request("projectId must not be empty"));
}
let project = self
.thread_store
.read_project(project_id.clone())
.await
.map_err(|err| match err {
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
err => internal_error(format!("failed to read project: {err}")),
})?;
if project.is_none() {
return Err(invalid_request(format!("project not found: {project_id}")));
}
}
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
let environments =
resolve_turn_environment_selections(self.thread_manager.as_ref(), environments)?;
@@ -1130,12 +1191,14 @@ impl ThreadRequestProcessor {
};
let request_trace = request_context.request_trace();
let config_manager = self.config_manager.clone();
let thread_store = Arc::clone(&self.thread_store);
let initial_config_warnings = Arc::clone(&self.initial_config_warnings);
let outgoing = Arc::clone(&listener_task_context.outgoing);
let error_request_id = request_id.clone();
let thread_start_task = async move {
if let Err(error) = Self::thread_start_task(
listener_task_context,
thread_store,
config_manager,
request_id,
app_server_client_name,
@@ -1148,6 +1211,7 @@ impl ThreadRequestProcessor {
history_mode.map(Into::into),
session_start_source,
thread_source.map(Into::into),
project_id,
environments,
service_name,
allow_provider_model_fallback,
@@ -1213,6 +1277,7 @@ impl ThreadRequestProcessor {
#[allow(clippy::too_many_arguments)]
async fn thread_start_task(
listener_task_context: ListenerTaskContext,
thread_store: Arc<dyn ThreadStore>,
config_manager: ConfigManager,
request_id: ConnectionRequestId,
app_server_client_name: Option<String>,
@@ -1225,6 +1290,7 @@ impl ThreadRequestProcessor {
history_mode: Option<ThreadHistoryMode>,
session_start_source: Option<codex_app_server_protocol::ThreadStartSource>,
thread_source: Option<codex_protocol::protocol::ThreadSource>,
project_id: Option<String>,
environment_selections: Option<Vec<TurnEnvironmentSelection>>,
service_name: Option<String>,
allow_provider_model_fallback: bool,
@@ -1340,13 +1406,21 @@ impl ThreadRequestProcessor {
if !selected_capability_roots.is_empty() {
thread_extension_init.insert(selected_capability_roots);
}
let mut start_options = StartThreadOptions::new(config);
let reserved_thread_id = if start_options.config.ephemeral {
None
} else {
stage_pending_project_metadata(
listener_task_context.thread_manager.as_ref(),
thread_store.as_ref(),
project_id.as_deref(),
"thread/start",
)
.await?
};
start_options.reserved_thread_id = reserved_thread_id;
let create_thread_started_at = std::time::Instant::now();
let NewThread {
thread_id,
thread,
session_configured,
..
} = listener_task_context
let new_thread = listener_task_context
.thread_manager
.start_thread(StartThreadOptions {
allow_provider_model_fallback,
@@ -1364,21 +1438,32 @@ impl ThreadRequestProcessor {
environments: Some(environments),
thread_extension_init,
client_mcp_extensions,
..StartThreadOptions::new(config)
..start_options
})
.instrument(tracing::info_span!(
"app_server.thread_start.create_thread",
otel.name = "app_server.thread_start.create_thread",
thread_start.dynamic_tool_count = dynamic_tool_count,
))
.await
.map_err(|err| match err.details() {
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
CodexErrorDetails::UnsupportedOperation(message) => {
method_not_found(message.clone())
}
_ => internal_error(format!("error creating thread: {err}")),
})?;
.await;
let NewThread {
thread_id,
thread,
session_configured,
..
} = match new_thread {
Ok(new_thread) => new_thread,
Err(err) => {
remove_pending_project_metadata(thread_store.as_ref(), reserved_thread_id).await;
return Err(match err.details() {
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
CodexErrorDetails::UnsupportedOperation(message) => {
method_not_found(message.clone())
}
_ => internal_error(format!("error creating thread: {err}")),
});
}
};
let session_telemetry = thread.session_telemetry();
session_telemetry.record_startup_phase(
"thread_start_create_thread",
@@ -1408,6 +1493,7 @@ impl ThreadRequestProcessor {
&config_snapshot,
session_configured.rollout_path.clone(),
);
thread.project_id = project_id.clone();
// Auto-attach a thread listener when starting a thread.
log_listener_attach_result(
@@ -1755,13 +1841,31 @@ impl ThreadRequestProcessor {
) -> Result<ThreadMetadataUpdateResponse, JSONRPCErrorError> {
let ThreadMetadataUpdateParams {
thread_id,
project_id,
git_info,
} = params;
let thread_uuid = ThreadId::from_string(&thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
if git_info.is_none() {
if let Some(project_id) = project_id.as_ref()
&& !project_id.is_empty()
{
let project = self
.thread_store
.read_project(project_id.clone())
.await
.map_err(|err| match err {
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
err => internal_error(format!("failed to read project: {err}")),
})?;
if project.is_none() {
return Err(invalid_request(format!("project not found: {project_id}")));
}
}
if git_info.is_none() && project_id.is_none() {
return Err(invalid_request(
"thread metadata update must include at least one field",
));
@@ -1793,16 +1897,63 @@ impl ThreadRequestProcessor {
)
.transpose()?;
let project_update: StoreClearableField<String> = if let Some(project_id) = project_id {
if project_id.is_empty() {
Some(None)
} else {
Some(Some(project_id))
}
} else {
None
};
let updated_thread = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
let previous_project_id = if project_update.is_some() {
Some(
self.thread_store
.read_thread(StoreReadThreadParams {
thread_id: thread_uuid,
include_archived: true,
include_history: false,
})
.await
.map_err(|err| match err {
ThreadStoreError::ThreadNotFound { .. } => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
err => internal_error(format!("failed to read thread metadata: {err}")),
})?
.project_id,
)
} else {
None
};
let patch = StoreThreadMetadataPatch {
git_info,
project_id: project_update.clone(),
..Default::default()
};
self.thread_manager
let updated_thread = self
.thread_manager
.update_thread_metadata(thread_uuid, patch, /*include_archived*/ true)
.await
.map_err(|err| core_thread_write_error("update thread metadata", err))?
.map_err(|err| core_thread_write_error("update thread metadata", err))?;
if let Some(project_id) = project_update.as_ref()
&& previous_project_id.as_ref() != Some(project_id)
{
self.outgoing
.send_server_notification(ServerNotification::ThreadProjectUpdated(
ThreadProjectUpdatedNotification {
thread_id: thread_id.clone(),
project_id: project_id.clone(),
},
))
.await;
}
updated_thread
};
let (mut thread, _) = thread_from_stored_thread(
updated_thread,
@@ -2303,12 +2454,33 @@ impl ThreadRequestProcessor {
source_kinds,
archived,
section_id,
project_id,
cwd,
use_state_db_only,
search_term,
parent_thread_id,
ancestor_thread_id,
} = params;
if project_id.is_some() && !self.thread_store.supports_projects() {
return Err(unsupported_thread_store_operation("projects"));
}
if let Some(Some(project_id)) = project_id.as_ref() {
if project_id.is_empty() {
return Err(invalid_params("projectId must not be empty"));
}
match self.thread_store.read_project(project_id.clone()).await {
Ok(Some(_)) => {}
Ok(None) => {
return Err(invalid_params(format!("project not found: {project_id}")));
}
Err(ThreadStoreError::Unsupported { operation }) => {
return Err(unsupported_thread_store_operation(operation));
}
Err(err) => {
return Err(internal_error(format!("failed to read project: {err}")));
}
}
}
let cwd_filters = normalize_thread_list_cwd_filters(cwd)?;
let relation_filter = match (parent_thread_id, ancestor_thread_id) {
(Some(_), Some(_)) => {
@@ -2354,6 +2526,7 @@ impl ThreadRequestProcessor {
source_kinds,
archived: archived.unwrap_or(false),
section_id,
project_id,
cwd_filters,
search_term,
use_state_db_only,
@@ -4588,6 +4761,18 @@ impl ThreadRequestProcessor {
let ephemeral_token_usage_turn_id = (ephemeral && include_turns)
.then(|| restored_token_usage_turn_id(&history_items, ephemeral_turns.as_slice()));
let token_usage_history_items = paginated_source.then(|| Arc::clone(&history_items));
let inherited_project_id = source_thread.project_id.clone();
let reserved_thread_id = if config.ephemeral {
None
} else {
stage_pending_project_metadata(
self.thread_manager.as_ref(),
self.thread_store.as_ref(),
inherited_project_id.as_deref(),
"thread/fork",
)
.await?
};
let new_thread = if let Some(prepared_fork) = prepared_fork {
self.thread_manager
@@ -4596,7 +4781,8 @@ impl ThreadRequestProcessor {
prepared_fork,
thread_source,
parent_trace,
client_mcp_extensions.clone(),
client_mcp_extensions,
reserved_thread_id,
)
.await
} else {
@@ -4612,6 +4798,7 @@ impl ThreadRequestProcessor {
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
)
.await
};
@@ -4620,13 +4807,20 @@ impl ThreadRequestProcessor {
thread: forked_thread,
session_configured,
..
} = new_thread.map_err(|err| match err.details() {
CodexErrorDetails::Io(_) | CodexErrorDetails::Json(_) => {
invalid_request(format!("failed to load thread {source_thread_id}: {err}"))
} = match new_thread {
Ok(new_thread) => new_thread,
Err(err) => {
remove_pending_project_metadata(self.thread_store.as_ref(), reserved_thread_id)
.await;
return Err(match err.details() {
CodexErrorDetails::Io(_) | CodexErrorDetails::Json(_) => {
invalid_request(format!("failed to load thread {source_thread_id}: {err}"))
}
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
_ => internal_error(format!("error forking thread: {err}")),
});
}
CodexErrorDetails::InvalidRequest(message) => invalid_request(message.clone()),
_ => internal_error(format!("error forking thread: {err}")),
})?;
};
Self::set_app_server_client_info(
forked_thread.as_ref(),
@@ -4754,6 +4948,9 @@ impl ThreadRequestProcessor {
));
thread.session_id = session_configured.session_id.to_string();
thread.thread_source = config_snapshot.thread_source.clone().map(Into::into);
if thread.path.is_none() {
thread.project_id = inherited_project_id.clone();
}
self.thread_watch_manager
.upsert_thread_silently(&thread.id)
@@ -4769,7 +4966,6 @@ impl ThreadRequestProcessor {
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
let thread_originator = config_snapshot.originator.clone();
let response = ThreadForkResponse {
thread: thread.clone(),
model: session_configured.model,
@@ -4872,6 +5068,7 @@ impl ThreadRequestProcessor {
source_kinds,
archived,
section_id,
project_id,
cwd_filters,
search_term,
use_state_db_only,
@@ -4920,6 +5117,7 @@ impl ThreadRequestProcessor {
cwd_filters: cwd_filters.clone(),
archived,
section: section_id.clone(),
project_id: project_id.clone(),
search_term: search_term.clone(),
use_state_db_only,
relation_filter,
@@ -5528,6 +5726,7 @@ pub(crate) fn thread_from_stored_thread(
section_entered_at: thread
.section_entered_at
.map(|entered_at| entered_at.timestamp()),
project_id: thread.project_id,
history_mode: thread.history_mode.into(),
model_provider: if thread.model_provider.is_empty() {
fallback_provider.to_string()
@@ -5702,6 +5901,7 @@ fn build_thread_from_snapshot(
ephemeral: config_snapshot.ephemeral,
section: None,
section_entered_at: None,
project_id: None,
history_mode: config_snapshot.history_mode.into(),
model_provider: config_snapshot.model_provider_id.clone(),
created_at: now,
@@ -606,6 +606,7 @@ mod thread_processor_behavior_tests {
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
cwd: PathBuf::from("/tmp"),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
@@ -202,6 +202,7 @@ mod tests {
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "mock_provider".to_string(),
created_at: 0,
@@ -309,6 +309,7 @@ pub(crate) fn summary_to_thread(
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: ThreadHistoryMode::Legacy,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
@@ -73,6 +73,9 @@ use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::ProcessKillParams;
use codex_app_server_protocol::ProcessSpawnParams;
use codex_app_server_protocol::ProjectImportParams;
use codex_app_server_protocol::ProjectListParams;
use codex_app_server_protocol::ProjectReadParams;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlPairingStartParams;
@@ -473,6 +476,33 @@ impl TestAppServer {
self.send_request("thread/start", params).await
}
/// Send a `project/import` JSON-RPC request.
pub async fn send_project_import_request(
&mut self,
params: ProjectImportParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("project/import", params).await
}
/// Send a `project/list` JSON-RPC request.
pub async fn send_project_list_request(
&mut self,
params: ProjectListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("project/list", params).await
}
/// Send a project/read JSON-RPC request.
pub async fn send_project_read_request(
&mut self,
params: ProjectReadParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("project/read", params).await
}
/// Sends a `thread/start` request selecting the builder's automatic
/// environment. Returns an error if `params` already select environments
/// so the caller cannot accidentally override the fixture.
@@ -528,6 +528,7 @@ source = {:?}
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -1891,6 +1892,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
@@ -2078,6 +2080,7 @@ required = true
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2162,6 +2165,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2243,6 +2247,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions()
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2370,6 +2375,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2485,6 +2491,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -61,6 +61,7 @@ mod plugin_search;
mod plugin_share;
mod plugin_uninstall;
mod process_exec;
mod projects;
mod rate_limit_reset_credits;
mod rate_limits;
mod realtime_conversation;
@@ -0,0 +1,906 @@
use std::collections::BTreeMap;
use anyhow::Result;
use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::ProjectChangeType;
use codex_app_server_protocol::ProjectChangedNotification;
use codex_app_server_protocol::ProjectCreateParams;
use codex_app_server_protocol::ProjectCreateResponse;
use codex_app_server_protocol::ProjectDeleteParams;
use codex_app_server_protocol::ProjectDeleteResponse;
use codex_app_server_protocol::ProjectImportParams;
use codex_app_server_protocol::ProjectImportResponse;
use codex_app_server_protocol::ProjectListParams;
use codex_app_server_protocol::ProjectListResponse;
use codex_app_server_protocol::ProjectMoveParams;
use codex_app_server_protocol::ProjectMoveResponse;
use codex_app_server_protocol::ProjectReadParams;
use codex_app_server_protocol::ProjectReadResponse;
use codex_app_server_protocol::ProjectRoot;
use codex_app_server_protocol::ProjectUpdateParams;
use codex_app_server_protocol::ProjectUpdateResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadProjectUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
#[tokio::test]
async fn projects_persist_and_assign_threads() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri())
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let root = AbsolutePathBuf::from_absolute_path(codex_home.path())?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let created: ProjectCreateResponse = server
.request(|request_id| ClientRequest::ProjectCreate {
request_id,
params: ProjectCreateParams {
name: " Work ".to_string(),
roots: vec![ProjectRoot { path: root.clone() }],
metadata: Some(BTreeMap::from([("color".to_string(), "blue".to_string())])),
idempotency_key: "projects-persist-primary".to_string(),
},
})
.await?;
assert_eq!(created.project.name, "Work");
assert_eq!(Uuid::parse_str(&created.project.id)?.get_version_num(), 7);
let read: ProjectReadResponse = server
.request(|request_id| ClientRequest::ProjectRead {
request_id,
params: ProjectReadParams {
project_id: created.project.id.clone(),
},
})
.await?;
assert_eq!(read.project, created.project);
server.clear_message_buffer();
let started_id = server
.send_thread_start_request_with_auto_env(ThreadStartParams {
project_id: Some(created.project.id.clone()),
..Default::default()
})
.await?;
let JSONRPCMessage::Response(response) = server.read_next_message().await? else {
panic!("thread/start must respond before lifecycle notifications");
};
assert_eq!(response.id, RequestId::Integer(started_id));
let started: ThreadStartResponse = serde_json::from_value(response.result)?;
let JSONRPCMessage::Notification(thread_started) = server.read_next_message().await? else {
panic!("thread/start must emit thread/started");
};
assert_eq!(thread_started.method, "thread/started");
assert_eq!(started.thread.project_id, Some(created.project.id.clone()));
server.clear_message_buffer();
let ephemeral_id = server
.send_thread_start_request_with_auto_env(ThreadStartParams {
project_id: Some(created.project.id.clone()),
ephemeral: Some(true),
..Default::default()
})
.await?;
let JSONRPCMessage::Response(response) = server.read_next_message().await? else {
panic!("ephemeral thread/start must respond before lifecycle notifications");
};
assert_eq!(response.id, RequestId::Integer(ephemeral_id));
let ephemeral: ThreadStartResponse = serde_json::from_value(response.result)?;
let JSONRPCMessage::Notification(thread_started) = server.read_next_message().await? else {
panic!("ephemeral thread/start must emit thread/started");
};
assert_eq!(thread_started.method, "thread/started");
assert_eq!(
ephemeral.thread.project_id,
Some(created.project.id.clone())
);
assert!(ephemeral.thread.ephemeral);
assert!(ephemeral.thread.path.is_none());
server
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: started.thread.id.clone(),
input: vec![UserInput::Text {
text: "persist this thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
drop(server);
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let resumed: ThreadResumeResponse = server
.request(|request_id| ClientRequest::ThreadResume {
request_id,
params: ThreadResumeParams {
thread_id: started.thread.id.clone(),
..Default::default()
},
})
.await?;
assert_eq!(resumed.thread.project_id, Some(created.project.id.clone()));
let read_after_resume: ThreadReadResponse = server
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: started.thread.id.clone(),
include_turns: false,
},
})
.await?;
assert_eq!(
read_after_resume.thread.project_id,
Some(created.project.id.clone())
);
let listed: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(Some(created.project.id.clone())),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?;
assert_eq!(listed.data.len(), 1);
assert_eq!(listed.data[0].id, started.thread.id);
server.clear_message_buffer();
let cleared: ThreadMetadataUpdateResponse = server
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(String::new()),
git_info: None,
},
})
.await?;
assert_eq!(cleared.thread.project_id, None);
let cleared_notification: ThreadProjectUpdatedNotification =
server.read_notification("thread/project/updated").await?;
assert_eq!(
cleared_notification,
ThreadProjectUpdatedNotification {
thread_id: started.thread.id.clone(),
project_id: None,
}
);
let unassigned: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(None),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?;
assert_eq!(unassigned.data.len(), 1);
assert_eq!(unassigned.data[0].id, started.thread.id);
server.clear_message_buffer();
let reassigned: ThreadMetadataUpdateResponse = server
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(created.project.id.clone()),
git_info: None,
},
})
.await?;
assert_eq!(
reassigned.thread.project_id,
Some(created.project.id.clone())
);
let reassigned_notification: ThreadProjectUpdatedNotification =
server.read_notification("thread/project/updated").await?;
assert_eq!(
reassigned_notification,
ThreadProjectUpdatedNotification {
thread_id: started.thread.id.clone(),
project_id: Some(created.project.id.clone()),
}
);
let reassigned_list: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(Some(created.project.id.clone())),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?;
assert_eq!(reassigned_list.data.len(), 1);
assert_eq!(reassigned_list.data[0].id, started.thread.id);
server.clear_message_buffer();
let unchanged: ThreadMetadataUpdateResponse = server
.request(|request_id| ClientRequest::ThreadMetadataUpdate {
request_id,
params: ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(created.project.id.clone()),
git_info: None,
},
})
.await?;
assert_eq!(
unchanged.thread.project_id,
Some(created.project.id.clone())
);
assert!(
!server
.pending_notification_methods()
.iter()
.any(|method| method == "thread/project/updated")
);
let updated: ProjectUpdateResponse = server
.request(|request_id| ClientRequest::ProjectUpdate {
request_id,
params: ProjectUpdateParams {
project_id: created.project.id.clone(),
name: Some("Renamed".to_string()),
roots: Some(Vec::new()),
metadata: Some(BTreeMap::new()),
},
})
.await?;
assert_eq!(updated.project.name, "Renamed");
assert!(updated.project.roots.is_empty());
assert!(updated.project.metadata.is_empty());
let updated_notification: ProjectChangedNotification =
server.read_notification("project/changed").await?;
assert_eq!(
updated_notification,
ProjectChangedNotification {
project_id: updated.project.id.clone(),
change_type: ProjectChangeType::Updated,
}
);
let unchanged_project: ProjectUpdateResponse = server
.request(|request_id| ClientRequest::ProjectUpdate {
request_id,
params: ProjectUpdateParams {
project_id: updated.project.id.clone(),
name: Some(updated.project.name.clone()),
roots: Some(updated.project.roots.clone()),
metadata: Some(updated.project.metadata.clone()),
},
})
.await?;
assert_eq!(unchanged_project.project, updated.project);
assert!(
!server
.pending_notification_methods()
.iter()
.any(|method| method == "project/changed")
);
let listed_projects: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(listed_projects.data, vec![updated.project.clone()]);
let second: ProjectCreateResponse = server
.request(|request_id| ClientRequest::ProjectCreate {
request_id,
params: ProjectCreateParams {
name: "Second".to_string(),
roots: Vec::new(),
metadata: None,
idempotency_key: "projects-persist-second".to_string(),
},
})
.await?;
server.clear_message_buffer();
let _: ProjectMoveResponse = server
.request(|request_id| ClientRequest::ProjectMove {
request_id,
params: ProjectMoveParams {
project_id: second.project.id.clone(),
before_project_id: Some(updated.project.id.clone()),
},
})
.await?;
let changed: ProjectChangedNotification = server.read_notification("project/changed").await?;
assert_eq!(changed.project_id, second.project.id);
let reordered: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(
reordered
.data
.iter()
.map(|project| project.name.as_str())
.collect::<Vec<_>>(),
vec!["Second", "Renamed"]
);
let moved_second = reordered.data[0].clone();
server.clear_message_buffer();
let deleted_project_id = created.project.id.clone();
let _: ProjectDeleteResponse = server
.request(|request_id| ClientRequest::ProjectDelete {
request_id,
params: ProjectDeleteParams {
project_id: deleted_project_id.clone(),
},
})
.await?;
let deleted_notification: ProjectChangedNotification =
server.read_notification("project/changed").await?;
assert_eq!(deleted_notification.project_id, deleted_project_id);
assert_eq!(deleted_notification.change_type, ProjectChangeType::Deleted);
let unassigned_notification: ThreadProjectUpdatedNotification =
server.read_notification("thread/project/updated").await?;
assert_eq!(
unassigned_notification,
ThreadProjectUpdatedNotification {
thread_id: started.thread.id.clone(),
project_id: None,
}
);
let read_id = server
.send_project_read_request(ProjectReadParams {
project_id: deleted_project_id,
})
.await?;
let read_error = server
.read_stream_until_error_message(RequestId::Integer(read_id))
.await?;
assert_eq!(read_error.error.code, -32602);
let remaining_projects: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(remaining_projects.data, vec![moved_second]);
let unassigned_after_delete: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(None),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?;
assert_eq!(unassigned_after_delete.data.len(), 1);
assert_eq!(unassigned_after_delete.data[0].id, started.thread.id);
assert!(codex_home.path().exists());
Ok(())
}
#[tokio::test]
async fn deleted_project_is_dropped_before_first_durable_thread_persistence() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri())
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let project: ProjectCreateResponse = server
.request(|request_id| ClientRequest::ProjectCreate {
request_id,
params: ProjectCreateParams {
name: "Pending".to_string(),
roots: Vec::new(),
metadata: None,
idempotency_key: "deleted-before-persistence".to_string(),
},
})
.await?;
server.clear_message_buffer();
let started = server
.start_thread(ThreadStartParams {
project_id: Some(project.project.id.clone()),
..Default::default()
})
.await?;
let _: serde_json::Value = server.read_notification("thread/started").await?;
server.clear_message_buffer();
let _: ProjectDeleteResponse = server
.request(|request_id| ClientRequest::ProjectDelete {
request_id,
params: ProjectDeleteParams {
project_id: project.project.id.clone(),
},
})
.await?;
let _: ProjectChangedNotification = server.read_notification("project/changed").await?;
let read_after_delete: ThreadReadResponse = server
.request(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: started.thread.id.clone(),
include_turns: false,
},
})
.await?;
assert_eq!(read_after_delete.thread.project_id, None);
server
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: started.thread.id.clone(),
input: vec![UserInput::Text {
text: "persist after project deletion".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let unassigned: ThreadListResponse = server
.request(|request_id| ClientRequest::ThreadList {
request_id,
params: ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(None),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
},
})
.await?;
assert_eq!(unassigned.data.len(), 1);
assert_eq!(unassigned.data[0].id, started.thread.id);
Ok(())
}
#[tokio::test]
async fn project_import_is_atomic_and_notifies_after_commit_in_order() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri())
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let started = server.start_thread(ThreadStartParams::default()).await?;
let _: serde_json::Value = server.read_notification("thread/started").await?;
server
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: started.thread.id.clone(),
input: vec![UserInput::Text {
text: "persist this thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
server.clear_message_buffer();
let import_id = server
.send_project_import_request(ProjectImportParams {
name: "Imported".to_string(),
roots: Vec::new(),
metadata: None,
threads: Some(vec![started.thread.id.clone()]),
idempotency_key: "desktop:legacy-project".to_string(),
})
.await?;
let JSONRPCMessage::Notification(project_changed) = server.read_next_message().await? else {
panic!("project/import must emit project/changed before its response");
};
assert_eq!(project_changed.method, "project/changed");
let project_changed: ProjectChangedNotification =
serde_json::from_value(project_changed.params.expect("notification params"))?;
let JSONRPCMessage::Notification(thread_updated) = server.read_next_message().await? else {
panic!("project/import must emit thread/project/updated after project/changed");
};
assert_eq!(thread_updated.method, "thread/project/updated");
let thread_updated: ThreadProjectUpdatedNotification =
serde_json::from_value(thread_updated.params.expect("notification params"))?;
assert_eq!(thread_updated.thread_id, started.thread.id);
assert_eq!(
thread_updated.project_id,
Some(project_changed.project_id.clone())
);
let JSONRPCMessage::Response(response) = server.read_next_message().await? else {
panic!("project/import must respond after commit notifications");
};
assert_eq!(response.id, RequestId::Integer(import_id));
let imported: ProjectImportResponse = serde_json::from_value(response.result)?;
server.clear_message_buffer();
let replayed: ProjectImportResponse = server
.request(|request_id| ClientRequest::ProjectImport {
request_id,
params: ProjectImportParams {
name: "Changed payload".to_string(),
roots: Vec::new(),
metadata: None,
threads: Some(vec![started.thread.id.clone()]),
idempotency_key: "desktop:legacy-project".to_string(),
},
})
.await?;
assert_eq!(replayed.project, imported.project);
assert!(
!server
.pending_notification_methods()
.iter()
.any(|method| method == "project/changed" || method == "thread/project/updated")
);
let listed: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(listed.data.len(), 1);
let duplicate_id = server
.send_project_import_request(ProjectImportParams {
name: "Duplicate".to_string(),
roots: Vec::new(),
metadata: None,
threads: Some(vec![started.thread.id.clone(), started.thread.id.clone()]),
idempotency_key: "duplicate-thread-import".to_string(),
})
.await?;
let duplicate = server
.read_stream_until_error_message(RequestId::Integer(duplicate_id))
.await?;
assert_eq!(duplicate.error.code, -32602);
let listed: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(listed.data.len(), 1);
let ephemeral = server
.start_thread(ThreadStartParams {
ephemeral: Some(true),
..Default::default()
})
.await?;
let ephemeral_id = server
.send_project_import_request(ProjectImportParams {
name: "Ephemeral".to_string(),
roots: Vec::new(),
metadata: None,
threads: Some(vec![ephemeral.thread.id]),
idempotency_key: "ephemeral-thread-import".to_string(),
})
.await?;
let ephemeral_error = server
.read_stream_until_error_message(RequestId::Integer(ephemeral_id))
.await?;
assert_eq!(ephemeral_error.error.code, -32602);
let listed: ProjectListResponse = server
.request(|request_id| ClientRequest::ProjectList {
request_id,
params: ProjectListParams {
cursor: None,
limit: Some(10),
},
})
.await?;
assert_eq!(listed.data.len(), 1);
Ok(())
}
#[tokio::test]
async fn projects_validate_filters_cursors_and_sqlite_less_assignment() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri())
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
for project_id in [String::new(), Uuid::now_v7().to_string()] {
let request_id = server
.send_thread_list_request(ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(Some(project_id)),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let error = server
.read_stream_until_error_message(RequestId::Integer(request_id))
.await?;
assert_eq!(error.error.code, -32602);
}
let cursor_id = server
.send_project_list_request(ProjectListParams {
cursor: Some("123|not-a-uuid|extra".to_string()),
limit: Some(10),
})
.await?;
let cursor_error = server
.read_stream_until_error_message(RequestId::Integer(cursor_id))
.await?;
assert_eq!(cursor_error.error.code, -32602);
let started = server.start_thread(ThreadStartParams::default()).await?;
let metadata_id = server
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: started.thread.id.clone(),
project_id: Some(Uuid::now_v7().to_string()),
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: Some(Some("abc123".to_string())),
branch: None,
origin_url: None,
}),
})
.await?;
let metadata_error = server
.read_stream_until_error_message(RequestId::Integer(metadata_id))
.await?;
assert_eq!(metadata_error.error.code, -32600);
let unchanged = server
.request::<ThreadReadResponse>(|request_id| ClientRequest::ThreadRead {
request_id,
params: ThreadReadParams {
thread_id: started.thread.id.clone(),
include_turns: false,
},
})
.await?;
assert_eq!(unchanged.thread.git_info, None);
let unsupported_projects_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri()).write(unsupported_projects_home.path())?;
let store_id = Uuid::now_v7();
std::fs::write(
unsupported_projects_home.path().join("config.toml"),
format!("experimental_thread_store = {{ type = \"in_memory\", id = \"{store_id}\" }}"),
)?;
let mut unsupported_projects = TestAppServer::builder()
.with_codex_home(unsupported_projects_home.path())
.build_initialized()
.await?;
let start_id = unsupported_projects
.send_thread_start_request_with_auto_env(ThreadStartParams {
project_id: Some(Uuid::now_v7().to_string()),
..Default::default()
})
.await?;
let error: JSONRPCError = unsupported_projects
.read_stream_until_error_message(RequestId::Integer(start_id))
.await?;
assert_eq!(error.error.code, -32601);
let list_id = unsupported_projects
.send_thread_list_request(ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(Vec::new()),
archived: None,
section_id: None,
project_id: Some(None),
cwd: None,
use_state_db_only: true,
search_term: None,
parent_thread_id: None,
ancestor_thread_id: None,
})
.await?;
let list_error = unsupported_projects
.read_stream_until_error_message(RequestId::Integer(list_id))
.await?;
assert_eq!(list_error.error.code, -32601);
let unassigned = unsupported_projects
.start_thread(ThreadStartParams::default())
.await?;
let _: serde_json::Value = unsupported_projects
.read_notification("thread/started")
.await?;
assert_eq!(unassigned.thread.project_id, None);
Ok(())
}
#[tokio::test]
async fn assigned_forks_inherit_projects_for_persistent_and_ephemeral_children() -> Result<()> {
let responses = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&responses.uri())
.enable_feature(Feature::Sqlite)
.write(codex_home.path())?;
let mut server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let project: ProjectCreateResponse = server
.request(|request_id| ClientRequest::ProjectCreate {
request_id,
params: ProjectCreateParams {
name: "Work".to_string(),
roots: Vec::new(),
metadata: None,
idempotency_key: "fork-inheritance".to_string(),
},
})
.await?;
let started = server
.start_thread(ThreadStartParams {
project_id: Some(project.project.id.clone()),
..Default::default()
})
.await?;
let _: serde_json::Value = server.read_notification("thread/started").await?;
server
.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: started.thread.id.clone(),
input: vec![UserInput::Text {
text: "persist this thread".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
server.clear_message_buffer();
let fork_id = server
.send_thread_fork_request(ThreadForkParams {
thread_id: started.thread.id.clone(),
..Default::default()
})
.await?;
let JSONRPCMessage::Response(response) = server.read_next_message().await? else {
panic!("thread/fork must respond before lifecycle notifications");
};
assert_eq!(response.id, RequestId::Integer(fork_id));
let forked: ThreadForkResponse = serde_json::from_value(response.result)?;
let _: serde_json::Value = server.read_notification("thread/started").await?;
assert_eq!(forked.thread.project_id, Some(project.project.id.clone()));
server.clear_message_buffer();
let ephemeral_id = server
.send_thread_fork_request(ThreadForkParams {
thread_id: started.thread.id,
ephemeral: true,
..Default::default()
})
.await?;
let JSONRPCMessage::Response(response) = server.read_next_message().await? else {
panic!("ephemeral thread/fork must respond before lifecycle notifications");
};
assert_eq!(response.id, RequestId::Integer(ephemeral_id));
let ephemeral_fork: ThreadForkResponse = serde_json::from_value(response.result)?;
let _: serde_json::Value = server.read_notification("thread/started").await?;
assert_eq!(ephemeral_fork.thread.project_id, Some(project.project.id));
assert!(ephemeral_fork.thread.ephemeral);
assert!(ephemeral_fork.thread.path.is_none());
Ok(())
}
@@ -279,6 +279,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -1325,6 +1325,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<(
history_mode: None,
session_start_source: None,
thread_source: None,
project_id: None,
dynamic_tools: None,
environments: None,
selected_capability_roots: None,
@@ -97,6 +97,7 @@ async fn list_threads(mcp: &mut TestAppServer) -> Result<ThreadListResponse> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -107,6 +107,7 @@ async fn list_threads_with_sort(
source_kinds,
archived,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -145,6 +146,7 @@ async fn list_threads_for_relation(
source_kinds,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
@@ -549,6 +551,7 @@ async fn thread_list_respects_cwd_filters() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::Many(vec![
first_target_cwd.to_string_lossy().into_owned(),
second_target_cwd.to_string_lossy().into_owned(),
@@ -661,6 +664,7 @@ sqlite = true
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: Some("needle".to_string()),
@@ -947,6 +951,7 @@ sqlite = true
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -982,6 +987,7 @@ sqlite = true
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
@@ -1010,6 +1016,7 @@ sqlite = true
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: Some(ThreadListCwdFilter::One(
stale_cwd.to_string_lossy().into_owned(),
)),
@@ -1194,6 +1201,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -1219,6 +1227,7 @@ async fn thread_list_relation_filters_reject_invalid_requests() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -1455,6 +1464,7 @@ async fn thread_list_reports_loaded_subagent_direct_input_capability() -> Result
source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
@@ -2213,6 +2223,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2253,6 +2264,7 @@ async fn thread_list_backwards_cursor_can_seed_forward_delta_sync() -> Result<()
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -2489,6 +2501,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -177,6 +177,7 @@ async fn thread_section_move_pins_and_unpins_with_filtered_recency_pagination()
source_kinds: None,
archived: None,
section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())),
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -367,6 +368,7 @@ async fn thread_sections_preserve_server_owned_manual_order_across_moves_and_res
source_kinds: None,
archived: None,
section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())),
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -490,6 +492,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread()
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/sidebar-pr".to_string())),
@@ -588,6 +591,7 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> {
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id,
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: None,
@@ -638,6 +642,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> {
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread.id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/ephemeral".to_string())),
@@ -712,6 +717,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/stored-thread".to_string())),
@@ -796,6 +802,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary(
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/loaded-thread".to_string())),
@@ -863,6 +870,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread()
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/archived-thread".to_string())),
@@ -923,6 +931,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: Some(None),
branch: Some(None),
@@ -255,6 +255,7 @@ async fn paginated_stored_thread_routes_projected_turns() -> Result<()> {
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -941,6 +942,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()>
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: false,
search_term: None,
@@ -1320,6 +1322,7 @@ async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume
source_kinds: None,
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
@@ -3264,6 +3264,7 @@ async fn thread_resume_prefers_persisted_git_metadata_for_local_threads() -> Res
let update_id = mcp
.send_thread_metadata_update_request(ThreadMetadataUpdateParams {
thread_id: thread_id.clone(),
project_id: None,
git_info: Some(ThreadMetadataGitInfoUpdateParams {
sha: None,
branch: Some(Some("feature/pr-branch".to_string())),
@@ -3666,6 +3666,7 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
]),
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
+1
View File
@@ -142,6 +142,7 @@ async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
relation_filter: None,
archived: false,
section: None,
project_id: None,
search_term: None,
use_state_db_only: false,
})
@@ -52,6 +52,7 @@ fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThre
.single()
.expect("valid timestamp"),
archived_at: None,
project_id: None,
section: None,
section_position: None,
section_entered_at: None,
+8
View File
@@ -1159,6 +1159,7 @@ impl ThreadManager {
thread_source,
parent_trace,
ClientMcpExtensions::default(),
/*reserved_thread_id*/ None,
)
.await
}
@@ -1182,6 +1183,7 @@ impl ThreadManager {
}
/// Fork an existing thread from already-loaded store history.
#[allow(clippy::too_many_arguments)]
pub async fn fork_thread_from_history<S>(
&self,
snapshot: S,
@@ -1190,6 +1192,7 @@ impl ThreadManager {
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
@@ -1204,6 +1207,7 @@ impl ThreadManager {
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
)
.await
}
@@ -1216,6 +1220,7 @@ impl ThreadManager {
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let history = InitialHistory::Resumed(ResumedHistory {
conversation_id: prepared.source_thread_id,
@@ -1237,6 +1242,7 @@ impl ThreadManager {
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
)
.await;
drop(prepared);
@@ -1250,6 +1256,7 @@ impl ThreadManager {
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
client_mcp_extensions: ClientMcpExtensions,
reserved_thread_id: Option<ThreadId>,
) -> CodexResult<NewThread> {
let ForkHistory {
snapshot,
@@ -1282,6 +1289,7 @@ impl ThreadManager {
thread_source,
parent_trace,
client_mcp_extensions,
reserved_thread_id,
..StartThreadOptions::new(config)
};
let mut request =
+1
View File
@@ -228,6 +228,7 @@ async fn assert_copied_fork_persists_inherited_history(history_mode: ThreadHisto
/*thread_source*/ None,
/*parent_trace*/ None,
ClientMcpExtensions::default(),
/*reserved_thread_id*/ None,
)
.await
.expect("fork from stored history");
+2
View File
@@ -1591,6 +1591,7 @@ async fn resolve_resume_thread_id(
source_kinds: Some(all_thread_source_kinds()),
archived: Some(false),
section_id: None,
project_id: None,
parent_thread_id: None,
ancestor_thread_id: None,
cwd: None,
@@ -1675,6 +1676,7 @@ async fn resolve_resume_thread_id(
source_kinds: Some(all_thread_source_kinds()),
archived: Some(false),
section_id: None,
project_id: None,
parent_thread_id: None,
ancestor_thread_id: None,
cwd: None,
+2
View File
@@ -337,6 +337,7 @@ fn turn_items_for_thread_returns_matching_turn_items() {
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 0,
@@ -816,6 +817,7 @@ fn sample_thread_start_response() -> ThreadStartResponse {
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 0,
+3
View File
@@ -59,6 +59,8 @@ pub struct ThreadItem {
pub preview: Option<String>,
/// The user-selected section in SQLite-owned metadata.
pub section: Option<codex_state::ThreadSection>,
/// Canonical project assignment in SQLite-owned metadata.
pub project_id: Option<String>,
/// Working directory from session metadata.
pub cwd: Option<PathBuf>,
/// Git branch from session metadata.
@@ -835,6 +837,7 @@ async fn build_thread_item(
first_user_message,
preview,
section: None,
project_id: None,
cwd,
git_branch,
git_sha,
+8
View File
@@ -496,6 +496,7 @@ impl RolloutRecorder {
/*relation_filter*/ None,
archived,
/*section*/ None,
/*project_id*/ None,
search_term,
)
.await
@@ -606,6 +607,7 @@ impl RolloutRecorder {
/*relation_filter*/ None,
archived,
/*section*/ None,
/*project_id*/ None,
search_term,
)
.await;
@@ -636,6 +638,7 @@ impl RolloutRecorder {
/*relation_filter*/ None,
archived,
/*section*/ None,
/*project_id*/ None,
search_term,
)
.await
@@ -677,6 +680,7 @@ impl RolloutRecorder {
/*relation_filter*/ None,
archived,
/*section*/ None,
/*project_id*/ None,
search_term,
)
.await
@@ -757,6 +761,7 @@ impl RolloutRecorder {
/*relation_filter*/ None,
/*archived*/ false,
/*section*/ None,
/*project_id*/ None,
/*search_term*/ None,
)
.await
@@ -1265,6 +1270,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
first_user_message,
preview,
section,
project_id,
cwd,
git_branch,
git_sha,
@@ -1288,6 +1294,7 @@ fn fill_missing_thread_item_metadata(item: &mut ThreadItem, state_item: ThreadIt
item.preview = preview;
}
item.section = section;
item.project_id = project_id;
if item.cwd.is_none() {
item.cwd = cwd;
}
@@ -2001,6 +2008,7 @@ fn thread_item_from_state_metadata(
first_user_message: item.first_user_message,
preview: item.preview,
section: item.section,
project_id: item.project_id,
cwd: Some(item.cwd),
git_branch: item.git_branch,
git_sha: item.git_sha,
+2
View File
@@ -1512,6 +1512,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
thread_id: Some(filesystem_thread_id),
first_user_message: Some("filesystem message".to_string()),
preview: Some("filesystem preview".to_string()),
project_id: None,
section: None,
cwd: None,
git_branch: Some("filesystem-branch".to_string()),
@@ -1533,6 +1534,7 @@ fn fill_missing_thread_item_metadata_preserves_identity_and_prefers_state_git_fi
thread_id: Some(state_thread_id),
first_user_message: Some("state message".to_string()),
preview: Some("state preview".to_string()),
project_id: None,
section: Some(codex_state::ThreadSection {
id: codex_state::PINNED_THREAD_SECTION_ID.to_string(),
name: codex_state::PINNED_THREAD_SECTION_NAME.to_string(),
+3
View File
@@ -362,6 +362,7 @@ pub async fn list_threads_db(
relation_filter: Option<codex_state::ThreadRelationFilter>,
archived: bool,
section: Option<Option<&str>>,
project_id: Option<Option<&str>>,
search_term: Option<&str>,
) -> Option<codex_state::ThreadsPage> {
let ctx = context?;
@@ -407,6 +408,7 @@ pub async fn list_threads_db(
allowed_sources: allowed_sources.as_slice(),
model_providers: model_providers.as_deref(),
cwd_filters: normalized_cwd_filters.as_deref(),
project_id,
anchor: anchor.as_ref(),
sort_key: state_sort_key,
sort_direction: state_sort_direction,
@@ -434,6 +436,7 @@ pub async fn list_threads_db(
allowed_sources: allowed_sources.as_slice(),
model_providers: model_providers.as_deref(),
cwd_filters: normalized_cwd_filters.as_deref(),
project_id,
anchor: anchor.as_ref(),
sort_key: state_sort_key,
sort_direction: state_sort_direction,
+1
View File
@@ -84,6 +84,7 @@ async fn list_threads_db_rejects_mismatched_sqlite_config_without_cleanup() -> a
/*relation_filter*/ None,
/*archived*/ false,
/*section*/ None,
/*project_id*/ None,
/*search_term*/ None,
)
.await;
+11
View File
@@ -723,6 +723,7 @@ async fn test_list_conversations_latest_first() {
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -744,6 +745,7 @@ async fn test_list_conversations_latest_first() {
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -765,6 +767,7 @@ async fn test_list_conversations_latest_first() {
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -879,6 +882,7 @@ async fn test_pagination_cursor() {
thread_id: Some(thread_id_from_uuid(u5)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -900,6 +904,7 @@ async fn test_pagination_cursor() {
thread_id: Some(thread_id_from_uuid(u4)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -957,6 +962,7 @@ async fn test_pagination_cursor() {
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -978,6 +984,7 @@ async fn test_pagination_cursor() {
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1027,6 +1034,7 @@ async fn test_pagination_cursor() {
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1201,6 +1209,7 @@ async fn test_get_thread_contents() {
thread_id: Some(thread_id_from_uuid(uuid)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1602,6 +1611,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -1623,6 +1633,7 @@ async fn test_timestamp_only_cursor_skips_same_second_filesystem_ties() {
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
preview: Some("Hello from user".to_string()),
project_id: None,
section: None,
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
@@ -0,0 +1,30 @@
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
metadata TEXT NOT NULL DEFAULT '{}',
position INTEGER NOT NULL,
created_at_ms INTEGER NOT NULL,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE project_roots (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (project_id, position)
);
CREATE TABLE project_idempotency_keys (
key TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
created_at_ms INTEGER NOT NULL
);
ALTER TABLE threads ADD COLUMN project_id TEXT
REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX idx_projects_position
ON projects(position ASC, id ASC);
CREATE INDEX idx_threads_project_id
ON threads(project_id, archived, created_at_ms DESC, id DESC)
WHERE project_id IS NOT NULL;
+1
View File
@@ -703,6 +703,7 @@ mod tests {
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
+4
View File
@@ -19,10 +19,14 @@ mod runtime;
mod sqlite;
mod telemetry;
pub use model::CreatedProject;
pub use model::LogEntry;
pub use model::LogQuery;
pub use model::LogRow;
pub use model::Phase2JobClaimOutcome;
pub use model::Project;
pub use model::ProjectRoot;
pub use model::ProjectsPage;
pub use model::QueuedUserSubmissionRecord;
pub use model::RolloutMigrationCursor;
pub use model::RolloutMigrationSkippedRollout;
+5
View File
@@ -2,6 +2,7 @@ mod backfill_state;
mod graph;
mod log;
mod memories;
mod project;
mod queued_item;
mod rollout_migration_state;
mod thread_goal;
@@ -18,6 +19,10 @@ pub use memories::Stage1JobClaim;
pub use memories::Stage1JobClaimOutcome;
pub use memories::Stage1Output;
pub use memories::Stage1StartupClaimParams;
pub use project::CreatedProject;
pub use project::Project;
pub use project::ProjectRoot;
pub use project::ProjectsPage;
pub use queued_item::QueuedUserSubmissionRecord;
pub use rollout_migration_state::RolloutMigrationCursor;
pub use rollout_migration_state::RolloutMigrationSkippedRollout;
+32
View File
@@ -0,0 +1,32 @@
use std::collections::BTreeMap;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectRoot {
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub roots: Vec<ProjectRoot>,
pub metadata: BTreeMap<String, String>,
pub position: i64,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CreatedProject {
pub project: Project,
pub created: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectsPage {
pub projects: Vec<Project>,
pub next_cursor: Option<String>,
}
@@ -176,6 +176,8 @@ pub struct ThreadMetadata {
pub section_position: Option<i64>,
/// The time when the thread most recently entered its current section.
pub section_entered_at: Option<DateTime<Utc>>,
/// Canonical project assignment owned by app-server, if any.
pub project_id: Option<String>,
/// The git commit SHA, if known.
pub git_sha: Option<String>,
/// The git branch name, if known.
@@ -309,6 +311,7 @@ impl ThreadMetadataBuilder {
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
git_sha: self.git_sha.clone(),
git_branch: self.git_branch.clone(),
git_origin_url: self.git_origin_url.clone(),
@@ -432,6 +435,9 @@ impl ThreadMetadata {
if self.section_entered_at != other.section_entered_at {
diffs.push("section_entered_at");
}
if self.project_id != other.project_id {
diffs.push("project_id");
}
if self.git_sha != other.git_sha {
diffs.push("git_sha");
}
@@ -480,6 +486,7 @@ pub(crate) struct ThreadRow {
section_appearance: Option<String>,
section_position: Option<i64>,
section_entered_at_ms: Option<i64>,
project_id: Option<String>,
git_sha: Option<String>,
git_branch: Option<String>,
git_origin_url: Option<String>,
@@ -517,6 +524,7 @@ impl ThreadRow {
section_appearance: row.try_get("section_appearance")?,
section_position: row.try_get("section_position")?,
section_entered_at_ms: row.try_get("section_entered_at_ms")?,
project_id: row.try_get("project_id")?,
git_sha: row.try_get("git_sha")?,
git_branch: row.try_get("git_branch")?,
git_origin_url: row.try_get("git_origin_url")?,
@@ -558,6 +566,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
section_appearance,
section_position,
section_entered_at_ms,
project_id,
git_sha,
git_branch,
git_origin_url,
@@ -614,6 +623,7 @@ impl TryFrom<ThreadRow> for ThreadMetadata {
section_entered_at: section_entered_at_ms
.map(epoch_millis_to_datetime)
.transpose()?,
project_id,
git_sha,
git_branch,
git_origin_url,
@@ -720,6 +730,7 @@ mod tests {
section_appearance: None,
section_position: None,
section_entered_at_ms: None,
project_id: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
@@ -756,6 +767,7 @@ mod tests {
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
+1
View File
@@ -44,6 +44,7 @@ mod external_agent_config_imports;
mod goals;
mod logs;
mod memories;
mod projects;
mod queued_items;
mod recovery;
mod remote_control;
+3
View File
@@ -208,6 +208,7 @@ SELECT
) AS section_appearance,
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -222,6 +223,7 @@ FROM threads
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor: None,
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
@@ -596,6 +598,7 @@ SELECT
) AS section_appearance,
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
+492
View File
@@ -0,0 +1,492 @@
use std::collections::BTreeMap;
use sqlx::Row;
use uuid::Uuid;
use super::StateRuntime;
use crate::CreatedProject;
use crate::Project;
use crate::ProjectRoot;
use crate::ProjectsPage;
impl StateRuntime {
pub async fn set_thread_project(
&self,
thread_id: &str,
project_id: Option<&str>,
) -> anyhow::Result<Option<Option<String>>> {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
if let Some(project_id) = project_id {
let exists = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects WHERE id = ?")
.bind(project_id)
.fetch_one(&mut *tx)
.await?;
if exists == 0 {
tx.rollback().await?;
anyhow::bail!("project not found: {project_id}");
}
}
let previous =
sqlx::query_scalar::<_, Option<String>>("SELECT project_id FROM threads WHERE id = ?")
.bind(thread_id)
.fetch_optional(&mut *tx)
.await?;
let Some(previous) = previous else {
tx.rollback().await?;
return Ok(None);
};
if previous.as_deref() != project_id {
sqlx::query("UPDATE threads SET project_id = ? WHERE id = ?")
.bind(project_id)
.bind(thread_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Some(previous))
}
pub async fn list_projects(
&self,
cursor: Option<&str>,
limit: usize,
) -> anyhow::Result<ProjectsPage> {
let anchor = cursor.map(parse_project_cursor).transpose()?;
let mut tx = self.pool.begin().await?;
let rows = if let Some((position, id)) = anchor {
sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects WHERE position > ? OR (position = ? AND id > ?) ORDER BY position ASC, id ASC LIMIT ?",
)
.bind(position)
.bind(position)
.bind(id)
.bind((limit + 1) as i64)
.fetch_all(&mut *tx)
.await?
} else {
sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects ORDER BY position ASC, id ASC LIMIT ?",
)
.bind((limit + 1) as i64)
.fetch_all(&mut *tx)
.await?
};
let project_ids = rows
.iter()
.map(|row| row.try_get("id"))
.collect::<Result<Vec<String>, _>>()?;
let mut roots_by_project_id = project_roots_by_id_in_tx(&mut tx, &project_ids).await?;
let mut projects = Vec::with_capacity(rows.len());
for row in rows {
let project_id: String = row.try_get("id")?;
let roots = roots_by_project_id.remove(&project_id).unwrap_or_default();
projects.push(project_from_row(&row, roots)?);
}
let next_cursor = (projects.len() > limit)
.then(|| project_cursor(&projects[limit - 1]))
.flatten();
projects.truncate(limit);
tx.commit().await?;
Ok(ProjectsPage {
projects,
next_cursor,
})
}
pub async fn get_project(&self, id: &str) -> anyhow::Result<Option<Project>> {
let mut tx = self.pool.begin().await?;
let row = sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects WHERE id = ?",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let project = match row {
Some(row) => Some(project_from_row_in_tx(&mut tx, &row).await?),
None => None,
};
tx.commit().await?;
Ok(project)
}
pub async fn get_project_by_idempotency_key(
&self,
idempotency_key: &str,
) -> anyhow::Result<Option<Project>> {
let mut tx = self.pool.begin().await?;
let project_id = sqlx::query_scalar::<_, String>(
"SELECT project_id FROM project_idempotency_keys WHERE key = ?",
)
.bind(idempotency_key)
.fetch_optional(&mut *tx)
.await?;
let Some(project_id) = project_id else {
tx.commit().await?;
return Ok(None);
};
let row = sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects WHERE id = ?",
)
.bind(&project_id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
anyhow::bail!("idempotency key refers to deleted project: {idempotency_key}");
};
let project = project_from_row_in_tx(&mut tx, &row).await?;
tx.commit().await?;
Ok(Some(project))
}
pub async fn create_project(
&self,
name: String,
roots: Vec<ProjectRoot>,
metadata: BTreeMap<String, String>,
thread_ids: &[String],
idempotency_key: &str,
) -> anyhow::Result<CreatedProject> {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let existing_project_id = sqlx::query_scalar::<_, String>(
"SELECT project_id FROM project_idempotency_keys WHERE key = ?",
)
.bind(idempotency_key)
.fetch_optional(&mut *tx)
.await?;
if let Some(existing_project_id) = existing_project_id {
let row = sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects WHERE id = ?",
)
.bind(&existing_project_id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
anyhow::bail!("idempotency key refers to deleted project: {idempotency_key}");
};
let project = project_from_row_in_tx(&mut tx, &row).await?;
tx.commit().await?;
return Ok(CreatedProject {
project,
created: false,
});
}
for thread_id in thread_ids {
let exists = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM threads WHERE id = ?")
.bind(thread_id)
.fetch_one(&mut *tx)
.await?;
if exists == 0 {
anyhow::bail!("thread not found: {thread_id}");
}
}
let id = Uuid::now_v7().to_string();
let now = chrono::Utc::now().timestamp_millis();
let position = sqlx::query_scalar::<_, Option<i64>>("SELECT MAX(position) FROM projects")
.fetch_one(&mut *tx)
.await?
.unwrap_or(-1)
.checked_add(1)
.ok_or_else(|| anyhow::anyhow!("project position overflow"))?;
let metadata_json = serde_json::to_string(&metadata)?;
sqlx::query(
"INSERT INTO projects (id, name, metadata, position, created_at_ms, updated_at_ms) VALUES (?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&name)
.bind(&metadata_json)
.bind(position)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await?;
replace_roots(&mut tx, &id, &roots).await?;
for thread_id in thread_ids {
sqlx::query("UPDATE threads SET project_id = ? WHERE id = ?")
.bind(&id)
.bind(thread_id)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO project_idempotency_keys (key, project_id, created_at_ms) VALUES (?, ?, ?)",
)
.bind(idempotency_key)
.bind(&id)
.bind(now)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(CreatedProject {
project: Project {
id,
name,
roots,
metadata,
position,
created_at_ms: now,
updated_at_ms: now,
},
created: true,
})
}
pub async fn update_project(
&self,
id: &str,
name: Option<String>,
roots: Option<Vec<ProjectRoot>>,
metadata: Option<BTreeMap<String, String>>,
) -> anyhow::Result<Option<(Project, bool)>> {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let row = sqlx::query(
"SELECT id, name, metadata, position, created_at_ms, updated_at_ms FROM projects WHERE id = ?",
)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
return Ok(None);
};
let current = project_from_row_in_tx(&mut tx, &row).await?;
let next_name = name.unwrap_or_else(|| current.name.clone());
let next_roots = roots.unwrap_or_else(|| current.roots.clone());
let next_metadata = metadata.unwrap_or_else(|| current.metadata.clone());
if next_name == current.name
&& next_roots == current.roots
&& next_metadata == current.metadata
{
tx.rollback().await?;
return Ok(Some((current, false)));
}
let now = chrono::Utc::now().timestamp_millis();
sqlx::query("UPDATE projects SET name = ?, metadata = ?, updated_at_ms = ? WHERE id = ?")
.bind(&next_name)
.bind(serde_json::to_string(&next_metadata)?)
.bind(now)
.bind(id)
.execute(&mut *tx)
.await?;
if next_roots != current.roots {
replace_roots(&mut tx, id, &next_roots).await?;
}
tx.commit().await?;
Ok(Some((
Project {
id: id.to_string(),
name: next_name,
roots: next_roots,
metadata: next_metadata,
position: current.position,
created_at_ms: current.created_at_ms,
updated_at_ms: now,
},
true,
)))
}
/// Move a project before another project, or append it when the anchor is absent.
///
/// Returns None when the moved project does not exist and Some(false) for a no-op.
pub async fn move_project(
&self,
project_id: &str,
before_project_id: Option<&str>,
) -> anyhow::Result<Option<bool>> {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let mut project_ids = sqlx::query_scalar::<_, String>(
"SELECT id FROM projects ORDER BY position ASC, id ASC",
)
.fetch_all(&mut *tx)
.await?;
let Some(current_index) = project_ids.iter().position(|id| id == project_id) else {
tx.rollback().await?;
return Ok(None);
};
if before_project_id == Some(project_id) {
tx.rollback().await?;
anyhow::bail!("project {project_id} cannot be moved before itself");
}
let original_project_ids = project_ids.clone();
project_ids.remove(current_index);
let next_index = if let Some(before_project_id) = before_project_id {
project_ids
.iter()
.position(|id| id == before_project_id)
.ok_or_else(|| anyhow::anyhow!("before project not found: {before_project_id}"))?
} else {
project_ids.len()
};
project_ids.insert(next_index, project_id.to_string());
if project_ids == original_project_ids {
tx.rollback().await?;
return Ok(Some(false));
}
for (position, id) in project_ids.iter().enumerate() {
sqlx::query("UPDATE projects SET position = ? WHERE id = ?")
.bind(position as i64)
.bind(id)
.execute(&mut *tx)
.await?;
}
sqlx::query("UPDATE projects SET updated_at_ms = ? WHERE id = ?")
.bind(chrono::Utc::now().timestamp_millis())
.bind(project_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some(true))
}
pub async fn delete_project(
&self,
id: &str,
) -> anyhow::Result<Option<(Vec<String>, Vec<String>)>> {
let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
let exists = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM projects WHERE id = ?")
.bind(id)
.fetch_one(&mut *tx)
.await?;
if exists == 0 {
tx.rollback().await?;
return Ok(None);
}
let active_thread_ids = sqlx::query_scalar::<_, String>(
"SELECT id FROM threads WHERE project_id = ? AND archived = 0 ORDER BY id ASC",
)
.bind(id)
.fetch_all(&mut *tx)
.await?;
let archived_thread_ids = sqlx::query_scalar::<_, String>(
"SELECT id FROM threads WHERE project_id = ? AND archived = 1 ORDER BY id ASC",
)
.bind(id)
.fetch_all(&mut *tx)
.await?;
sqlx::query("UPDATE threads SET project_id = NULL WHERE project_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM projects WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Some((active_thread_ids, archived_thread_ids)))
}
}
async fn project_from_row_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
row: &sqlx::sqlite::SqliteRow,
) -> anyhow::Result<Project> {
let id: String = row.try_get("id")?;
let roots =
sqlx::query("SELECT path FROM project_roots WHERE project_id = ? ORDER BY position ASC")
.bind(&id)
.fetch_all(&mut **tx)
.await?
.into_iter()
.map(|row| {
Ok(ProjectRoot {
path: row.try_get("path")?,
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
project_from_row(row, roots)
}
fn project_from_row(
row: &sqlx::sqlite::SqliteRow,
roots: Vec<ProjectRoot>,
) -> anyhow::Result<Project> {
Ok(Project {
id: row.try_get("id")?,
name: row.try_get("name")?,
roots,
metadata: serde_json::from_str(&row.try_get::<String, _>("metadata")?)?,
position: row.try_get("position")?,
created_at_ms: row.try_get("created_at_ms")?,
updated_at_ms: row.try_get("updated_at_ms")?,
})
}
async fn project_roots_by_id_in_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
project_ids: &[String],
) -> anyhow::Result<BTreeMap<String, Vec<ProjectRoot>>> {
if project_ids.is_empty() {
return Ok(BTreeMap::new());
}
let mut builder = sqlx::QueryBuilder::<sqlx::Sqlite>::new(
"SELECT project_id, path FROM project_roots WHERE project_id IN (",
);
let mut separated = builder.separated(", ");
for project_id in project_ids {
separated.push_bind(project_id);
}
builder.push(") ORDER BY project_id ASC, position ASC");
let rows = builder.build().fetch_all(&mut **tx).await?;
let mut roots_by_project_id = BTreeMap::<String, Vec<ProjectRoot>>::new();
for row in rows {
roots_by_project_id
.entry(row.try_get("project_id")?)
.or_default()
.push(ProjectRoot {
path: row.try_get("path")?,
});
}
Ok(roots_by_project_id)
}
async fn replace_roots(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
project_id: &str,
roots: &[ProjectRoot],
) -> anyhow::Result<()> {
sqlx::query("DELETE FROM project_roots WHERE project_id = ?")
.bind(project_id)
.execute(&mut **tx)
.await?;
for (position, root) in roots.iter().enumerate() {
sqlx::query("INSERT INTO project_roots (project_id, position, path) VALUES (?, ?, ?)")
.bind(project_id)
.bind(position as i64)
.bind(&root.path)
.execute(&mut **tx)
.await?;
}
Ok(())
}
fn project_cursor(project: &Project) -> Option<String> {
Some(format!("{}|{}", project.position, project.id))
}
fn parse_project_cursor(cursor: &str) -> anyhow::Result<(i64, String)> {
let mut parts = cursor.split('|');
let position_component = parts.next().ok_or_else(|| invalid_project_cursor(cursor))?;
let position: i64 = position_component
.parse()
.map_err(|_| invalid_project_cursor(cursor))?;
let id = parts.next().ok_or_else(|| invalid_project_cursor(cursor))?;
let uuid = Uuid::parse_str(id).map_err(|_| invalid_project_cursor(cursor))?;
if parts.next().is_some()
|| position < 0
|| position.to_string() != position_component
|| uuid.to_string() != id
{
return Err(invalid_project_cursor(cursor));
}
Ok((position, id.to_string()))
}
fn invalid_project_cursor(cursor: &str) -> anyhow::Error {
anyhow::anyhow!("invalid project cursor: {cursor}")
}
#[cfg(test)]
#[path = "projects_tests.rs"]
mod tests;
@@ -0,0 +1,461 @@
use std::collections::BTreeMap;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
use super::*;
use crate::ThreadMetadataBuilder;
use crate::runtime::test_support::unique_temp_dir;
#[tokio::test]
async fn project_lifecycle_preserves_order_and_clears_assignments() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let thread_id = ThreadId::default();
let metadata = ThreadMetadataBuilder::new(
thread_id,
home.join("thread.jsonl"),
chrono::Utc::now(),
SessionSource::Cli,
)
.build("test-provider");
runtime.upsert_thread(&metadata).await?;
let roots = vec![
ProjectRoot {
path: "/tmp/one".to_string(),
},
ProjectRoot {
path: "/tmp/two".to_string(),
},
];
let project = runtime
.create_project(
"Work".to_string(),
roots.clone(),
BTreeMap::from([("color".to_string(), "blue".to_string())]),
&[thread_id.to_string()],
"state:project-lifecycle",
)
.await?
.project;
assert_eq!(project.position, 0);
assert_eq!(project.roots, roots);
assert_eq!(
runtime.get_thread(thread_id).await?.unwrap().project_id,
Some(project.id.clone())
);
let unchanged = runtime
.update_project(
&project.id,
/*name*/ None,
/*roots*/ None,
/*metadata*/ None,
)
.await?
.unwrap();
assert!(!unchanged.1);
assert_eq!(unchanged.0.updated_at_ms, project.updated_at_ms);
runtime
.mark_archived(
thread_id,
home.join("archived.jsonl").as_path(),
chrono::Utc::now(),
)
.await?;
let active_thread_id = ThreadId::default();
let active_metadata = ThreadMetadataBuilder::new(
active_thread_id,
home.join("active.jsonl"),
chrono::Utc::now(),
SessionSource::Cli,
)
.build("test-provider");
runtime.upsert_thread(&active_metadata).await?;
runtime
.set_thread_project(&active_thread_id.to_string(), Some(&project.id))
.await?
.expect("active thread exists");
let (affected_active_thread_ids, affected_archived_thread_ids) =
runtime.delete_project(&project.id).await?.unwrap();
assert_eq!(
affected_active_thread_ids,
vec![active_thread_id.to_string()]
);
assert_eq!(affected_archived_thread_ids, vec![thread_id.to_string()]);
assert_eq!(
runtime.get_thread(thread_id).await?.unwrap().project_id,
None
);
assert_eq!(
runtime
.get_thread(active_thread_id)
.await?
.unwrap()
.project_id,
None
);
assert_eq!(runtime.get_project(&project.id).await?, None);
Ok(())
}
#[tokio::test]
async fn project_idempotency_keys_replay_and_survive_deletion() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let created = runtime
.create_project(
"Imported".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"desktop:legacy-project",
)
.await?;
assert!(created.created);
assert_eq!(
runtime
.get_project_by_idempotency_key("desktop:legacy-project")
.await?,
Some(created.project.clone())
);
let replayed = runtime
.create_project(
"Changed payload".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"desktop:legacy-project",
)
.await?;
assert!(!replayed.created);
assert_eq!(replayed.project, created.project);
runtime.delete_project(&created.project.id).await?;
let lookup_error = runtime
.get_project_by_idempotency_key("desktop:legacy-project")
.await
.expect_err("deleted project keys must remain tombstoned");
assert!(
lookup_error
.to_string()
.contains("idempotency key refers to deleted project")
);
let error = runtime
.create_project(
"Recreated".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"desktop:legacy-project",
)
.await
.expect_err("deleted project keys must remain tombstoned");
assert!(
error
.to_string()
.contains("idempotency key refers to deleted project")
);
Ok(())
}
#[tokio::test]
async fn project_import_rejects_unknown_thread_without_partial_project() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let error = runtime
.create_project(
"Work".to_string(),
Vec::new(),
BTreeMap::new(),
&["00000000-0000-0000-0000-000000000123".to_string()],
"state:unknown-thread",
)
.await
.unwrap_err();
assert!(error.to_string().contains("thread not found"));
assert!(
runtime
.list_projects(/*cursor*/ None, /*limit*/ 10)
.await?
.projects
.is_empty()
);
Ok(())
}
#[tokio::test]
async fn project_updates_preserve_omitted_fields_across_concurrent_writers() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime_one = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let runtime_two = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let project = runtime_one
.create_project(
"Work".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"state:concurrent-update",
)
.await?
.project;
let (name_update, metadata_update) = tokio::join!(
runtime_one.update_project(
&project.id,
Some("Renamed".to_string()),
/*roots*/ None,
/*metadata*/ None,
),
runtime_two.update_project(
&project.id,
/*name*/ None,
/*roots*/ None,
Some(BTreeMap::from([("color".to_string(), "blue".to_string())])),
),
);
name_update?.expect("project exists");
metadata_update?.expect("project exists");
let updated = runtime_one
.get_project(&project.id)
.await?
.expect("project exists");
assert_eq!(updated.name, "Renamed");
assert_eq!(
updated.metadata,
BTreeMap::from([("color".to_string(), "blue".to_string())])
);
Ok(())
}
#[tokio::test]
async fn project_list_rejects_malformed_cursors() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
for cursor in [
"",
"123",
"+123|00000000-0000-0000-0000-000000000000",
"000123|00000000-0000-0000-0000-000000000000",
"-0|00000000-0000-0000-0000-000000000000",
"-1|00000000-0000-0000-0000-000000000000",
"123|not-a-uuid",
"123|00000000000000000000000000000000",
"123|00000000-0000-0000-0000-00000000000A",
"123|00000000-0000-0000-0000-000000000000|extra",
"not-a-position|00000000-0000-0000-0000-000000000000",
] {
let error = runtime
.list_projects(Some(cursor), /*limit*/ 10)
.await
.expect_err("malformed cursor should fail");
assert!(error.to_string().starts_with("invalid project cursor:"));
}
Ok(())
}
#[tokio::test]
async fn project_list_cursor_round_trips_across_pages() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
for name in ["One", "Two"] {
runtime
.create_project(name.to_string(), Vec::new(), BTreeMap::new(), &[], name)
.await?;
}
let first = runtime.list_projects(/*cursor*/ None, /*limit*/ 1).await?;
assert_eq!(first.projects.len(), 1);
let cursor = first.next_cursor.expect("next cursor");
let second = runtime.list_projects(Some(&cursor), /*limit*/ 1).await?;
assert_eq!(second.projects.len(), 1);
assert_ne!(first.projects[0].id, second.projects[0].id);
assert_eq!(second.next_cursor, None);
Ok(())
}
#[tokio::test]
async fn project_move_reorders_projects_and_preserves_no_op_timestamp() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let one = runtime
.create_project(
"One".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"state:move-one",
)
.await?
.project;
let two = runtime
.create_project(
"Two".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"state:move-two",
)
.await?
.project;
let three = runtime
.create_project(
"Three".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"state:move-three",
)
.await?
.project;
assert_eq!(
runtime.move_project(&three.id, Some(&one.id)).await?,
Some(true)
);
let reordered = runtime.list_projects(/*cursor*/ None, /*limit*/ 10).await?;
assert_eq!(
reordered
.projects
.iter()
.map(|project| project.id.as_str())
.collect::<Vec<_>>(),
vec![three.id.as_str(), one.id.as_str(), two.id.as_str()]
);
assert_eq!(
reordered
.projects
.iter()
.map(|project| project.position)
.collect::<Vec<_>>(),
vec![0, 1, 2]
);
let moved = runtime.get_project(&three.id).await?.unwrap();
assert_eq!(
runtime.move_project(&three.id, Some(&one.id)).await?,
Some(false)
);
assert_eq!(
runtime.get_project(&three.id).await?.unwrap().updated_at_ms,
moved.updated_at_ms
);
assert_eq!(
runtime
.move_project(&three.id, Some(&three.id))
.await
.unwrap_err()
.to_string(),
format!("project {} cannot be moved before itself", three.id)
);
assert_eq!(
runtime
.move_project(&three.id, Some("00000000-0000-0000-0000-000000000000"))
.await
.unwrap_err()
.to_string(),
"before project not found: 00000000-0000-0000-0000-000000000000"
);
assert_eq!(
runtime
.move_project(
"00000000-0000-0000-0000-000000000000",
/*before_project_id*/ None,
)
.await?,
None
);
Ok(())
}
#[tokio::test]
async fn initial_project_assignment_is_inserted_with_thread_row() -> anyhow::Result<()> {
let home = unique_temp_dir();
let runtime = StateRuntime::init(
crate::SqliteConfig::new_for_testing(home.as_path().abs()),
"test-provider".to_string(),
)
.await?;
let project = runtime
.create_project(
"Work".to_string(),
Vec::new(),
BTreeMap::new(),
&[],
"state:initial-assignment",
)
.await?
.project;
let thread_id = ThreadId::default();
let mut metadata = ThreadMetadataBuilder::new(
thread_id,
home.join("thread.jsonl"),
chrono::Utc::now(),
SessionSource::Cli,
)
.build("test-provider");
metadata.project_id = Some(project.id.clone());
assert!(runtime.insert_thread_if_absent(&metadata).await?);
assert_eq!(
runtime.get_thread(thread_id).await?.unwrap().project_id,
Some(project.id.clone())
);
let missing_thread_id = ThreadId::default();
let mut missing_project_metadata = ThreadMetadataBuilder::new(
missing_thread_id,
home.join("missing-project.jsonl"),
chrono::Utc::now(),
SessionSource::Cli,
)
.build("test-provider");
missing_project_metadata.project_id = Some("00000000-0000-0000-0000-000000000000".to_string());
let error = runtime
.insert_thread_if_absent(&missing_project_metadata)
.await
.expect_err("unknown project must reject initial thread insert");
assert!(error.to_string().contains("FOREIGN KEY constraint failed"));
assert_eq!(runtime.get_thread(missing_thread_id).await?, None);
let deleted = runtime.delete_project(&project.id).await?.unwrap();
assert_eq!(deleted, (vec![thread_id.to_string()], Vec::new()));
Ok(())
}
@@ -72,6 +72,7 @@ pub(super) fn test_thread_metadata(
section: None,
section_position: None,
section_entered_at: None,
project_id: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
+37 -4
View File
@@ -46,6 +46,7 @@ SELECT
) AS section_appearance,
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -414,6 +415,7 @@ ON CONFLICT(child_thread_id) DO NOTHING
model_providers,
cwd_filters: None,
section: None,
project_id: None,
anchor: None,
sort_key: crate::SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
@@ -539,6 +541,7 @@ ON CONFLICT(child_thread_id) DO NOTHING
model_providers,
cwd_filters: None,
section: None,
project_id: None,
anchor,
sort_key,
sort_direction: SortDirection::Desc,
@@ -620,8 +623,9 @@ INSERT INTO threads (
git_sha,
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
memory_mode,
project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
"#,
)
@@ -670,6 +674,7 @@ ON CONFLICT(id) DO NOTHING
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind("enabled")
.bind(metadata.project_id.as_deref())
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
@@ -895,8 +900,9 @@ INSERT INTO threads (
git_sha,
git_branch,
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
memory_mode,
project_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
@@ -978,6 +984,7 @@ ON CONFLICT(id) DO UPDATE SET
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind(creation_memory_mode.unwrap_or("enabled"))
.bind(metadata.project_id.as_deref())
.execute(self.pool.as_ref())
.await?;
self.insert_thread_spawn_edge_from_source_if_absent(metadata.id, metadata.source.as_str())
@@ -1277,6 +1284,7 @@ SELECT
) AS section_appearance,
threads.section_position,
threads.section_entered_at_ms,
threads.project_id,
threads.git_sha,
threads.git_branch,
threads.git_origin_url
@@ -1311,6 +1319,7 @@ pub struct ThreadFilterOptions<'a> {
pub model_providers: Option<&'a [String]>,
pub cwd_filters: Option<&'a [PathBuf]>,
pub section: Option<Option<&'a str>>,
pub project_id: Option<Option<&'a str>>,
pub anchor: Option<&'a crate::Anchor>,
pub sort_key: SortKey,
pub sort_direction: SortDirection,
@@ -1342,6 +1351,7 @@ fn push_thread_filters_with_preview<'a>(
model_providers,
cwd_filters,
section,
project_id,
anchor,
sort_key,
sort_direction,
@@ -1366,6 +1376,16 @@ fn push_thread_filters_with_preview<'a>(
}
None => {}
}
match project_id {
Some(Some(project_id)) => {
builder.push(" AND threads.project_id = ");
builder.push_bind(project_id);
}
Some(None) => {
builder.push(" AND threads.project_id IS NULL");
}
None => {}
}
if !allowed_sources.is_empty() {
builder.push(" AND threads.source IN (");
let mut separated = builder.separated(", ");
@@ -1649,6 +1669,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section,
project_id: None,
anchor,
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
@@ -1782,6 +1803,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: Some(Some(CUSTOM_THREAD_SECTION_ID)),
project_id: None,
anchor,
sort_key: SortKey::SectionPosition,
sort_direction: SortDirection::Asc,
@@ -2029,6 +2051,7 @@ mod tests {
model_providers: Some(&model_providers),
cwd_filters: None,
section: None,
project_id: None,
anchor: Some(&anchor),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Asc,
@@ -2058,6 +2081,7 @@ mod tests {
model_providers: Some(&model_providers),
cwd_filters: None,
section: None,
project_id: None,
anchor: page.next_anchor.as_ref(),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Asc,
@@ -2115,6 +2139,7 @@ mod tests {
model_providers: None,
cwd_filters: Some(cwd_filters.as_slice()),
section: None,
project_id: None,
anchor: None,
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
@@ -2148,6 +2173,7 @@ mod tests {
model_providers: None,
cwd_filters: Some(cwd_filters.as_slice()),
section: None,
project_id: None,
anchor: first_page.next_anchor.as_ref(),
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
@@ -2174,6 +2200,7 @@ mod tests {
model_providers: None,
cwd_filters: Some(&[]),
section: None,
project_id: None,
anchor: None,
sort_key: SortKey::UpdatedAt,
sort_direction: SortDirection::Desc,
@@ -2242,6 +2269,7 @@ mod tests {
model_providers: Some(&model_providers),
cwd_filters,
section: None,
project_id: None,
anchor,
sort_key,
sort_direction: SortDirection::Desc,
@@ -2343,6 +2371,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor: None,
sort_key: SortKey::CreatedAt,
sort_direction: SortDirection::Desc,
@@ -2372,6 +2401,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor,
sort_key: SortKey::CreatedAt,
sort_direction: SortDirection::Desc,
@@ -3056,6 +3086,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor: None,
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
@@ -3089,6 +3120,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor: first_page.next_anchor.as_ref(),
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
@@ -3122,6 +3154,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
anchor: second_page.next_anchor.as_ref(),
sort_key: SortKey::RecencyAt,
sort_direction: SortDirection::Desc,
+10
View File
@@ -174,6 +174,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: Some(ThreadRelationFilter::DirectChildrenOf(parent_thread_id)),
@@ -202,6 +203,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: None,
project_id: None,
archived: false,
search_term: None,
relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)),
@@ -230,6 +232,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: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)),
@@ -258,6 +261,7 @@ mod tests {
model_providers: None,
cwd_filters: None,
section: Some(None),
project_id: None,
archived: false,
search_term: None,
relation_filter: Some(ThreadRelationFilter::DescendantsOf(parent_thread_id)),
@@ -704,6 +708,11 @@ impl InMemoryThreadStore {
&self,
params: UpdateThreadMetadataParams,
) -> ThreadStoreResult<StoredThread> {
if params.patch.project_id.is_some() {
return Err(ThreadStoreError::Unsupported {
operation: "projects",
});
}
let mut state = self.state.lock().await;
state.calls.update_thread_metadata += 1;
if !state.created_threads.contains_key(&params.thread_id) {
@@ -1067,6 +1076,7 @@ fn stored_thread_from_state(
}),
section_position: state.section_positions.get(&thread_id).copied(),
section_entered_at: state.section_entered_at.get(&thread_id).copied(),
project_id: None,
cwd: metadata
.and_then(|metadata| metadata.cwd.clone())
.unwrap_or_default(),
+12
View File
@@ -8,6 +8,7 @@ mod error;
mod in_memory;
mod live_thread;
mod local;
mod projects;
mod queue_store;
mod store;
mod thread_metadata_sync;
@@ -30,6 +31,17 @@ pub use local::RolloutMigrationOutcome;
pub use local::RolloutMigrationProgress;
pub use local::RolloutMigrationReport;
pub use local::RolloutMigrationStatus;
pub use projects::CreateProjectParams;
pub use projects::CreatedProject;
pub use projects::DeletedProject;
pub use projects::ListProjectsParams;
pub use projects::MoveProjectParams;
pub use projects::ProjectMoveOutcome;
pub use projects::StoredProject;
pub use projects::StoredProjectRoot;
pub use projects::StoredProjectsPage;
pub use projects::UpdateProjectParams;
pub use projects::UpdatedProject;
pub use queue_store::LocalQueueStore;
pub use queue_store::QueueStore;
pub use store::PersistContext;
@@ -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,
+76
View File
@@ -0,0 +1,76 @@
use std::collections::BTreeMap;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredProjectRoot {
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredProject {
pub id: String,
pub name: String,
pub roots: Vec<StoredProjectRoot>,
pub metadata: BTreeMap<String, String>,
pub position: i64,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredProjectsPage {
pub projects: Vec<StoredProject>,
pub next_cursor: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListProjectsParams {
pub cursor: Option<String>,
pub limit: usize,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateProjectParams {
pub name: String,
pub roots: Vec<StoredProjectRoot>,
pub metadata: BTreeMap<String, String>,
pub thread_ids: Vec<String>,
pub idempotency_key: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreatedProject {
pub project: StoredProject,
pub created: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdateProjectParams {
pub project_id: String,
pub name: Option<String>,
pub roots: Option<Vec<StoredProjectRoot>>,
pub metadata: Option<BTreeMap<String, String>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MoveProjectParams {
pub project_id: String,
pub before_project_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProjectMoveOutcome {
Unchanged,
Moved,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpdatedProject {
pub project: StoredProject,
pub changed: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DeletedProject {
pub affected_active_thread_ids: Vec<String>,
pub affected_archived_thread_ids: Vec<String>,
}
+75
View File
@@ -7,20 +7,26 @@ use std::pin::Pin;
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::PrepareForkParams;
use crate::PreparedFork;
use crate::ProjectMoveOutcome;
use crate::ReadThreadByRolloutPathParams;
use crate::ReadThreadParams;
use crate::RenameThreadSectionParams;
@@ -29,6 +35,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;
@@ -40,7 +48,9 @@ use crate::ThreadSearchPage;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
use crate::TurnPage;
use crate::UpdateProjectParams;
use crate::UpdateThreadMetadataParams;
use crate::UpdatedProject;
/// Future returned by [`ThreadStore`] operations.
pub type ThreadStoreFuture<'a, T> = Pin<Box<dyn Future<Output = ThreadStoreResult<T>> + Send + 'a>>;
@@ -241,6 +251,71 @@ pub trait ThreadStore: Any + Send + Sync {
})
}
/// Whether this store supports durable host-owned projects.
fn supports_projects(&self) -> bool {
false
}
fn list_projects(
&self,
_params: ListProjectsParams,
) -> ThreadStoreFuture<'_, StoredProjectsPage> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/list",
})
})
}
fn read_project(&self, _project_id: String) -> ThreadStoreFuture<'_, Option<StoredProject>> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/read",
})
})
}
fn create_project(
&self,
_params: CreateProjectParams,
) -> ThreadStoreFuture<'_, CreatedProject> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/create",
})
})
}
fn update_project(
&self,
_params: UpdateProjectParams,
) -> ThreadStoreFuture<'_, Option<UpdatedProject>> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/update",
})
})
}
fn move_project(
&self,
_params: MoveProjectParams,
) -> ThreadStoreFuture<'_, Option<ProjectMoveOutcome>> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/move",
})
})
}
fn delete_project(&self, _project_id: String) -> ThreadStoreFuture<'_, Option<DeletedProject>> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "project/delete",
})
})
}
/// Whether paginated threads can hydrate durable history through turn and item lists.
fn supports_paginated_history_lists(&self) -> bool {
false
@@ -429,8 +429,41 @@ mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::CreateThreadParams;
use crate::ThreadPersistenceMetadata;
#[tokio::test]
async fn create_without_project_omits_project_patch() {
let thread_id = ThreadId::new();
let sync = ThreadMetadataSync::for_create(&CreateThreadParams {
session_id: thread_id.into(),
thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: SessionSource::Exec,
thread_source: None,
originator: "test_originator".to_string(),
base_instructions: codex_protocol::models::BaseInstructions::default(),
dynamic_tools: Vec::new(),
selected_capability_roots: Vec::new(),
multi_agent_version: None,
history_mode: ThreadHistoryMode::Legacy,
history_base: None,
subagent_history_start_ordinal: None,
initial_window_id: uuid::Uuid::now_v7().to_string(),
metadata: ThreadPersistenceMetadata {
cwd: None,
model_provider: "test-provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
})
.await;
let update = sync.take_pending_update().expect("pending metadata update");
assert_eq!(update.patch.project_id, None);
}
#[test]
fn resume_history_keeps_derived_metadata_pending_until_applied() {
let thread_id = ThreadId::new();
+17
View File
@@ -314,6 +314,9 @@ pub struct ListThreadsParams {
/// Omit to include every section, set to `None` to match unsectioned
/// threads, or provide a section ID to match that section.
pub section: Option<Option<String>>,
/// Omit to include every project, set to None for unassigned threads,
/// or provide a project ID to match that project.
pub project_id: ClearableField<String>,
/// Whether archived threads should be listed instead of active threads.
pub archived: bool,
/// Optional substring/full-text search term for thread title/preview.
@@ -584,6 +587,9 @@ pub struct StoredThread {
/// The time when the thread most recently entered its current section.
#[serde(default)]
pub section_entered_at: Option<DateTime<Utc>>,
/// Canonical project assignment owned by app-server, if any.
#[serde(default)]
pub project_id: Option<String>,
/// Working directory captured for the thread.
pub cwd: PathBuf,
/// CLI version captured for the thread.
@@ -744,6 +750,13 @@ pub struct ThreadMetadataPatch {
pub git_info: Option<GitInfoPatch>,
/// Thread memory behavior.
pub memory_mode: Option<MemoryMode>,
/// Initial project assignment supplied with thread creation.
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub project_id: ClearableField<String>,
}
impl ThreadMetadataPatch {
@@ -824,6 +837,9 @@ impl ThreadMetadataPatch {
if next.memory_mode.is_some() {
self.memory_mode = next.memory_mode;
}
if next.project_id.is_some() {
self.project_id = next.project_id;
}
}
pub fn is_empty(&self) -> bool {
@@ -850,6 +866,7 @@ impl ThreadMetadataPatch {
&& self.first_user_message.is_none()
&& self.git_info.is_none()
&& self.memory_mode.is_none()
&& self.project_id.is_none()
}
}
+1
View File
@@ -45,6 +45,7 @@ impl App {
source_kinds: Some(vec![ThreadSourceKind::SubAgentThreadSpawn]),
archived: None,
section_id: None,
project_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
@@ -59,6 +59,9 @@ pub(super) fn server_notification_thread_target(
ServerNotification::ThreadNameUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadProjectUpdated(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::ThreadTokenUsageUpdated(notification) => {
Some(notification.thread_id.as_str())
}
@@ -168,7 +171,8 @@ pub(super) fn server_notification_thread_target(
None => return ServerNotificationThreadTarget::AppScoped,
}
}
ServerNotification::SkillsChanged(_)
ServerNotification::ProjectChanged(_)
| ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AccountUpdated(_)
| ServerNotification::AccountRateLimitsUpdated(_)
+1
View File
@@ -144,6 +144,7 @@ mod tests {
ephemeral: false,
section: None,
section_entered_at: None,
project_id: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: 0,

Some files were not shown because too many files have changed in this diff Show More