Add attachment owner lookup to the app-server (#50094)

## Why

`thread/attachment/list` lists attachments for a known thread. Clients also need to find the threads associated with a given attachment identity.

## What changed

Add `thread/attachmentOwner/list` to return owning thread IDs and archive status for an exact `attachmentType` and `identityKey`. Support cursor pagination and an optional `archived` filter, including both archived and non-archived threads by default.

Wire the request through the thread store and update documentation, protocol schemas, and generated TypeScript and Python types. The lookup is limited to the configured thread store and reports current membership, not an atomic resource-cleanup check.

## Testing

Extend app-server integration tests to cover exact type-and-key matching, multiple owners, archive filtering, and invalid cursor rejection.

GitOrigin-RevId: 30f4a20e00243d0023246d465a9b95213b3adeb6
This commit is contained in:
joeytrasatti-openai
2026-10-01 20:47:05 +00:00
committed by copyberry
parent 9d53dd91b5
commit 2635431edb
24 changed files with 694 additions and 2 deletions
@@ -4253,6 +4253,43 @@
],
"type": "object"
},
"ThreadAttachmentOwnerListParams": {
"description": "Parameters for reverse lookup by attachment identity within this server's store.",
"properties": {
"archived": {
"description": "Omitted or null returns all matches; false returns non-archived threads only.",
"type": [
"boolean",
"null"
]
},
"attachmentType": {
"type": "string"
},
"cursor": {
"type": [
"string",
"null"
]
},
"identityKey": {
"type": "string"
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"attachmentType",
"identityKey"
],
"type": "object"
},
"ThreadAttachmentRemoveParams": {
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
"properties": {
@@ -6342,6 +6379,30 @@
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachmentOwner/list"
],
"title": "Thread/attachmentOwner/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentOwnerListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachmentOwner/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -540,6 +540,30 @@
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/attachmentOwner/list"
],
"title": "Thread/attachmentOwner/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAttachmentOwnerListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachmentOwner/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -21177,6 +21201,85 @@
],
"type": "string"
},
"ThreadAttachmentOwner": {
"description": "A thread that currently owns an attachment with the requested identity.",
"properties": {
"archived": {
"description": "Whether the owning thread is archived, not whether it is currently executing a turn.",
"type": "boolean"
},
"threadId": {
"type": "string"
}
},
"required": [
"archived",
"threadId"
],
"type": "object"
},
"ThreadAttachmentOwnerListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for reverse lookup by attachment identity within this server's store.",
"properties": {
"archived": {
"description": "Omitted or null returns all matches; false returns non-archived threads only.",
"type": [
"boolean",
"null"
]
},
"attachmentType": {
"type": "string"
},
"cursor": {
"type": [
"string",
"null"
]
},
"identityKey": {
"type": "string"
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"attachmentType",
"identityKey"
],
"title": "ThreadAttachmentOwnerListParams",
"type": "object"
},
"ThreadAttachmentOwnerListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "One page of matching owners, including threads without their own user messages.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/ThreadAttachmentOwner"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentOwnerListResponse",
"type": "object"
},
"ThreadAttachmentRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
@@ -2089,6 +2089,30 @@
"title": "Thread/attachment/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/attachmentOwner/list"
],
"title": "Thread/attachmentOwner/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAttachmentOwnerListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/attachmentOwner/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -18891,6 +18915,85 @@
],
"type": "string"
},
"ThreadAttachmentOwner": {
"description": "A thread that currently owns an attachment with the requested identity.",
"properties": {
"archived": {
"description": "Whether the owning thread is archived, not whether it is currently executing a turn.",
"type": "boolean"
},
"threadId": {
"type": "string"
}
},
"required": [
"archived",
"threadId"
],
"type": "object"
},
"ThreadAttachmentOwnerListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for reverse lookup by attachment identity within this server's store.",
"properties": {
"archived": {
"description": "Omitted or null returns all matches; false returns non-archived threads only.",
"type": [
"boolean",
"null"
]
},
"attachmentType": {
"type": "string"
},
"cursor": {
"type": [
"string",
"null"
]
},
"identityKey": {
"type": "string"
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"attachmentType",
"identityKey"
],
"title": "ThreadAttachmentOwnerListParams",
"type": "object"
},
"ThreadAttachmentOwnerListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "One page of matching owners, including threads without their own user messages.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ThreadAttachmentOwner"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentOwnerListResponse",
"type": "object"
},
"ThreadAttachmentRemoveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for deleting an attachment by its stable thread-local identity.",
@@ -0,0 +1,39 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Parameters for reverse lookup by attachment identity within this server's store.",
"properties": {
"archived": {
"description": "Omitted or null returns all matches; false returns non-archived threads only.",
"type": [
"boolean",
"null"
]
},
"attachmentType": {
"type": "string"
},
"cursor": {
"type": [
"string",
"null"
]
},
"identityKey": {
"type": "string"
},
"limit": {
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"required": [
"attachmentType",
"identityKey"
],
"title": "ThreadAttachmentOwnerListParams",
"type": "object"
}
@@ -0,0 +1,42 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ThreadAttachmentOwner": {
"description": "A thread that currently owns an attachment with the requested identity.",
"properties": {
"archived": {
"description": "Whether the owning thread is archived, not whether it is currently executing a turn.",
"type": "boolean"
},
"threadId": {
"type": "string"
}
},
"required": [
"archived",
"threadId"
],
"type": "object"
}
},
"description": "One page of matching owners, including threads without their own user messages.",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ThreadAttachmentOwner"
},
"type": "array"
},
"nextCursor": {
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ThreadAttachmentOwnerListResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
// 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.
/**
* A thread that currently owns an attachment with the requested identity.
*/
export type ThreadAttachmentOwner = { threadId: string,
/**
* Whether the owning thread is archived, not whether it is currently executing a turn.
*/
archived: boolean, };
@@ -0,0 +1,12 @@
// 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.
/**
* Parameters for reverse lookup by attachment identity within this server's store.
*/
export type ThreadAttachmentOwnerListParams = { attachmentType: string, identityKey: string,
/**
* Omitted or null returns all matches; false returns non-archived threads only.
*/
archived?: boolean | null, cursor?: string | null, limit?: number | null, };
@@ -0,0 +1,9 @@
// 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 { ThreadAttachmentOwner } from "./ThreadAttachmentOwner";
/**
* One page of matching owners, including threads without their own user messages.
*/
export type ThreadAttachmentOwnerListResponse = { data: Array<ThreadAttachmentOwner>, nextCursor: string | null, };
@@ -486,6 +486,9 @@ export type { ThreadAttachmentAddResponse } from "./ThreadAttachmentAddResponse"
export type { ThreadAttachmentListParams } from "./ThreadAttachmentListParams";
export type { ThreadAttachmentListResponse } from "./ThreadAttachmentListResponse";
export type { ThreadAttachmentOperation } from "./ThreadAttachmentOperation";
export type { ThreadAttachmentOwner } from "./ThreadAttachmentOwner";
export type { ThreadAttachmentOwnerListParams } from "./ThreadAttachmentOwnerListParams";
export type { ThreadAttachmentOwnerListResponse } from "./ThreadAttachmentOwnerListResponse";
export type { ThreadAttachmentRemoveParams } from "./ThreadAttachmentRemoveParams";
export type { ThreadAttachmentRemoveResponse } from "./ThreadAttachmentRemoveResponse";
export type { ThreadAttachmentUpdatedNotification } from "./ThreadAttachmentUpdatedNotification";
@@ -678,6 +678,11 @@ client_request_definitions! {
serialization: None,
response: v2::ThreadAttachmentListResponse,
},
ThreadAttachmentOwnerList => "thread/attachmentOwner/list" {
params: v2::ThreadAttachmentOwnerListParams,
serialization: None,
response: v2::ThreadAttachmentOwnerListResponse,
},
ThreadAttachmentRemove => "thread/attachment/remove" {
params: v2::ThreadAttachmentRemoveParams,
serialization: thread_id(params.thread_id),
@@ -105,3 +105,38 @@ pub struct ThreadAttachmentUpdatedNotification {
pub attachment_id: String,
pub operation: ThreadAttachmentOperation,
}
/// Parameters for reverse lookup by attachment identity within this server's store.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentOwnerListParams {
pub attachment_type: String,
pub identity_key: String,
/// Omitted or null returns all matches; false returns non-archived threads only.
#[ts(optional = nullable)]
pub archived: Option<bool>,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
/// A thread that currently owns an attachment with the requested identity.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentOwner {
pub thread_id: String,
/// Whether the owning thread is archived, not whether it is currently executing a turn.
pub archived: bool,
}
/// One page of matching owners, including threads without their own user messages.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAttachmentOwnerListResponse {
pub data: Vec<ThreadAttachmentOwner>,
pub next_cursor: Option<String>,
}
+3
View File
@@ -247,6 +247,7 @@ the entire network configuration.
- `thread/attachment/add` — add a durable resource reference to a stored thread without loading it. Repeated writes with the same attachment type and identity key return the existing attachment.
- `thread/attachment/list` — list attachments for one stored thread in a cursor-paginated request, including a thread that is not loaded.
- `thread/attachmentOwner/list` — find stored threads with an exact attachment type and identity key, with cursor pagination and an optional archive filter.
- `thread/attachment/remove` — remove an attachment by its thread, attachment type, and identity key; returns `{}`.
- `thread/attachment/updated` — notification broadcast after an attachment is created or removed; contains the thread, attachment identity, attachment id, and operation.
### Example: Manage stored thread attachments
@@ -304,6 +305,8 @@ Attachments record the resources currently associated with a thread, independent
`thread/attachment/list` accepts one `threadId` and returns at most 100 attachments per page, ordered by creation time and attachment id. Continue with `nextCursor` and the same `threadId` until the cursor is `null`. Each thread can retain up to 100 attachments. Removing an attachment frees a slot for a new attachment.
`thread/attachmentOwner/list` performs the reverse lookup: pass `attachmentType` and `identityKey` to get `data: [{threadId, archived}]` and `nextCursor`. Omit `archived` (or pass `null`) to include both active and archived threads; `false` selects active threads and `true` selects archived threads. Use the same identity and archive filter on subsequent cursor pages. The lookup covers only this app-server's configured thread store, not other hosts or stores. Results describe current membership and are not an atomic resource-cleanup check: attachments can change after a lookup.
A non-ephemeral fork copies the source thread's current attachments, even when forking at an earlier turn. The copies have new attachment IDs and creation timestamps, but retain the same resource identities and payloads. Clients use `forkedFromId` on `thread/started` to detect forks and call `thread/attachment/list` with the new thread ID to load their attachments. Fork copying does not emit per-attachment updates; explicit add/remove operations still do. Copying is awaited before publishing the fork, but is best effort: a copy failure is logged and the conversation fork succeeds without attachments. Membership can then change independently on either thread; the referenced resources themselves are not copied. Resuming a fork does not repeat the copy.
Attachment creation and deletion requests using the same thread ID are serialized across connections. The requesting client receives its response before the compact update is broadcast, and duplicate creates or absent deletes do not emit updates. Deleting the owning thread removes its attachments under the same lifecycle exclusion; queued attachment mutations then report that the thread was not found.
@@ -1406,6 +1406,11 @@ impl MessageProcessor {
ClientRequest::ThreadAttachmentList { params, .. } => {
self.thread_processor.thread_attachment_list(params).await
}
ClientRequest::ThreadAttachmentOwnerList { params, .. } => {
self.thread_processor
.thread_attachment_owner_list(params)
.await
}
ClientRequest::ThreadAttachmentRemove { params, .. } => {
self.thread_processor
.thread_attachment_remove(request_id.clone(), params)
@@ -16,6 +16,9 @@ use codex_app_server_protocol::ThreadAttachmentAddResponse;
use codex_app_server_protocol::ThreadAttachmentListParams;
use codex_app_server_protocol::ThreadAttachmentListResponse;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentOwner;
use codex_app_server_protocol::ThreadAttachmentOwnerListParams;
use codex_app_server_protocol::ThreadAttachmentOwnerListResponse;
use codex_app_server_protocol::ThreadAttachmentRemoveParams;
use codex_app_server_protocol::ThreadAttachmentRemoveResponse;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
@@ -26,10 +29,12 @@ use codex_state::MAX_THREAD_ATTACHMENT_PAYLOAD_BYTES;
use codex_state::MAX_THREAD_ATTACHMENT_TYPE_BYTES;
use codex_thread_store::AddThreadAttachmentOutcome;
use codex_thread_store::AddThreadAttachmentParams;
use codex_thread_store::ListThreadAttachmentThreadsParams;
use codex_thread_store::ListThreadAttachmentsParams;
use codex_thread_store::RemoveThreadAttachmentOutcome;
use codex_thread_store::RemoveThreadAttachmentParams;
use codex_thread_store::ThreadAttachment as StoredThreadAttachment;
use codex_thread_store::ThreadAttachmentArchiveFilter;
use codex_thread_store::ThreadStoreError;
impl ThreadRequestProcessor {
@@ -131,6 +136,49 @@ impl ThreadRequestProcessor {
))
}
pub(crate) async fn thread_attachment_owner_list(
&self,
params: ThreadAttachmentOwnerListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
const OPERATION: &str = "thread/attachmentOwner/list";
self.ensure_thread_attachments_supported(OPERATION)?;
validate_attachment_identity(&params.attachment_type, &params.identity_key)?;
let limit = params
.limit
.map(|limit| limit as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, MAX_THREAD_ATTACHMENT_LIST_PAGE_SIZE);
let page = self
.thread_store
.list_thread_attachment_threads(ListThreadAttachmentThreadsParams {
attachment_type: params.attachment_type,
identity_key: params.identity_key,
archive_filter: match params.archived {
None => ThreadAttachmentArchiveFilter::All,
Some(false) => ThreadAttachmentArchiveFilter::NonArchived,
Some(true) => ThreadAttachmentArchiveFilter::Archived,
},
cursor: params.cursor,
limit,
})
.await
.map_err(|error| thread_attachment_store_error(OPERATION, error))?;
Ok(Some(
ThreadAttachmentOwnerListResponse {
data: page
.threads
.into_iter()
.map(|thread| ThreadAttachmentOwner {
thread_id: thread.thread_id.to_string(),
archived: thread.archived,
})
.collect(),
next_cursor: page.next_cursor,
}
.into(),
))
}
pub(crate) async fn thread_attachment_remove(
&self,
request_id: ConnectionRequestId,
@@ -19,6 +19,8 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadAttachment;
use codex_app_server_protocol::ThreadAttachmentAddOutcome;
use codex_app_server_protocol::ThreadAttachmentAddParams;
@@ -26,6 +28,9 @@ use codex_app_server_protocol::ThreadAttachmentAddResponse;
use codex_app_server_protocol::ThreadAttachmentListParams;
use codex_app_server_protocol::ThreadAttachmentListResponse;
use codex_app_server_protocol::ThreadAttachmentOperation;
use codex_app_server_protocol::ThreadAttachmentOwner;
use codex_app_server_protocol::ThreadAttachmentOwnerListParams;
use codex_app_server_protocol::ThreadAttachmentOwnerListResponse;
use codex_app_server_protocol::ThreadAttachmentRemoveParams;
use codex_app_server_protocol::ThreadAttachmentRemoveResponse;
use codex_app_server_protocol::ThreadAttachmentUpdatedNotification;
@@ -140,7 +145,7 @@ async fn thread_attachments_support_unloaded_listing_and_idempotent_attachment()
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: ThreadAttachmentAddParams {
thread_id: unrelated_thread,
thread_id: unrelated_thread.clone(),
attachment_type: "pull_request".to_string(),
identity_key: r#"["github.com","openai","codex",789]"#.to_string(),
payload: json!({ "url": "https://github.com/openai/codex/pull/789" }),
@@ -149,6 +154,97 @@ async fn thread_attachments_support_unloaded_listing_and_idempotent_attachment()
.await?;
assert_eq!(unrelated.outcome, ThreadAttachmentAddOutcome::Created);
// Neither the same type with another key nor the same key with another type matches.
let _: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: ThreadAttachmentAddParams {
thread_id: unrelated_thread.clone(),
attachment_type: "document".to_string(),
identity_key: first_attachment.identity_key.clone(),
payload: json!({}),
},
})
.await?;
let reverse_params = ThreadAttachmentOwnerListParams {
attachment_type: first_attachment.attachment_type.clone(),
identity_key: first_attachment.identity_key.clone(),
archived: None,
cursor: None,
limit: None,
};
let matched: ThreadAttachmentOwnerListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentOwnerList {
request_id,
params: reverse_params.clone(),
})
.await?;
let active_match = ThreadAttachmentOwner {
thread_id: first_thread.clone(),
archived: false,
};
assert_eq!(
matched,
ThreadAttachmentOwnerListResponse {
data: vec![active_match.clone()],
next_cursor: None,
}
);
let _: ThreadAttachmentAddResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentAdd {
request_id,
params: ThreadAttachmentAddParams {
thread_id: unrelated_thread.clone(),
attachment_type: first_attachment.attachment_type.clone(),
identity_key: first_attachment.identity_key.clone(),
payload: first_attachment.payload.clone(),
},
})
.await?;
let _: ThreadArchiveResponse = server
.request(|request_id| ClientRequest::ThreadArchive {
request_id,
params: ThreadArchiveParams {
thread_id: unrelated_thread.clone(),
},
})
.await?;
let all_matches: ThreadAttachmentOwnerListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentOwnerList {
request_id,
params: reverse_params.clone(),
})
.await?;
let archived_match = ThreadAttachmentOwner {
thread_id: unrelated_thread,
archived: true,
};
let mut expected_matches = vec![active_match, archived_match.clone()];
expected_matches.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
assert_eq!(
all_matches,
ThreadAttachmentOwnerListResponse {
data: expected_matches,
next_cursor: None,
}
);
let archived_matches: ThreadAttachmentOwnerListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentOwnerList {
request_id,
params: ThreadAttachmentOwnerListParams {
archived: Some(true),
..reverse_params
},
})
.await?;
assert_eq!(
archived_matches,
ThreadAttachmentOwnerListResponse {
data: vec![archived_match],
next_cursor: None,
}
);
let first_page: ThreadAttachmentListResponse = server
.request(|request_id| ClientRequest::ThreadAttachmentList {
request_id,
@@ -341,6 +437,10 @@ async fn thread_attachment_requests_reject_invalid_identities_and_cursors() -> R
"identityKey": " "
}),
),
(
"thread/attachmentOwner/list",
json!({ "attachmentType": "pull_request", "identityKey": "key", "cursor": "invalid-cursor" }),
),
("thread/attachment/list", json!({ "threadId": "invalid" })),
(
"thread/attachment/list",
+4
View File
@@ -23,6 +23,9 @@ pub use codex_state::ProjectSortKey;
pub use codex_state::QueuedUserSubmissionRecord;
pub use codex_state::RemoveThreadAttachmentOutcome;
pub use codex_state::ThreadAttachment;
pub use codex_state::ThreadAttachmentArchiveFilter;
pub use codex_state::ThreadAttachmentOwner;
pub use codex_state::ThreadAttachmentOwnerPage;
pub use codex_state::ThreadAttachmentPage;
pub use error::ThreadStoreError;
pub use error::ThreadStoreResult;
@@ -56,6 +59,7 @@ pub use store::PersistContext;
pub use store::ThreadStore;
pub use store::ThreadStoreFuture;
pub use thread_attachments::AddThreadAttachmentParams;
pub use thread_attachments::ListThreadAttachmentThreadsParams;
pub use thread_attachments::ListThreadAttachmentsParams;
pub use thread_attachments::RemoveThreadAttachmentParams;
pub use thread_sections::CreateThreadSectionParams;
+11
View File
@@ -77,6 +77,7 @@ use crate::DeletedProject;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListProjectsParams;
use crate::ListThreadAttachmentThreadsParams;
use crate::ListThreadAttachmentsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams;
@@ -105,6 +106,7 @@ use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadAttachmentOwnerPage;
use crate::ThreadAttachmentPage;
use crate::ThreadMetadataPatch;
use crate::ThreadOccurrenceSearchPage;
@@ -672,6 +674,15 @@ impl ThreadStore for LocalThreadStore {
Box::pin(async move { thread_attachments::list_thread_attachments(self, params).await })
}
fn list_thread_attachment_threads(
&self,
params: ListThreadAttachmentThreadsParams,
) -> ThreadStoreFuture<'_, ThreadAttachmentOwnerPage> {
Box::pin(
async move { thread_attachments::list_thread_attachment_threads(self, params).await },
)
}
fn remove_thread_attachment(
&self,
params: RemoveThreadAttachmentParams,
@@ -3,9 +3,11 @@
use super::LocalThreadStore;
use crate::AddThreadAttachmentOutcome;
use crate::AddThreadAttachmentParams;
use crate::ListThreadAttachmentThreadsParams;
use crate::ListThreadAttachmentsParams;
use crate::RemoveThreadAttachmentOutcome;
use crate::RemoveThreadAttachmentParams;
use crate::ThreadAttachmentOwnerPage;
use crate::ThreadAttachmentPage;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
@@ -61,6 +63,23 @@ pub(super) async fn list_thread_attachments(
.map_err(|error| attachment_error("list", /*thread_id*/ None, error))
}
pub(super) async fn list_thread_attachment_threads(
store: &LocalThreadStore,
params: ListThreadAttachmentThreadsParams,
) -> ThreadStoreResult<ThreadAttachmentOwnerPage> {
let state_db = state_db(store, "thread/attachmentOwner/list")?;
state_db
.list_thread_attachment_threads(
&params.attachment_type,
&params.identity_key,
params.archive_filter,
params.cursor.as_deref(),
params.limit,
)
.await
.map_err(|error| attachment_error("list owners of", /*thread_id*/ None, error))
}
pub(super) async fn remove_thread_attachment(
store: &LocalThreadStore,
params: RemoveThreadAttachmentParams,
+15
View File
@@ -22,6 +22,7 @@ use crate::DeletedProject;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListProjectsParams;
use crate::ListThreadAttachmentThreadsParams;
use crate::ListThreadAttachmentsParams;
use crate::ListThreadSectionsParams;
use crate::ListThreadsParams;
@@ -48,6 +49,7 @@ use crate::StoredThread;
use crate::StoredThreadHistory;
use crate::StoredThreadSection;
use crate::StoredThreadSectionsPage;
use crate::ThreadAttachmentOwnerPage;
use crate::ThreadAttachmentPage;
use crate::ThreadMetadataPatch;
use crate::ThreadOccurrenceSearchPage;
@@ -356,6 +358,19 @@ pub trait ThreadStore: Any + Send + Sync {
})
}
/// Lists owners of an exact attachment identity, without thread browser visibility filters.
/// Results describe persisted membership, not an atomic claim on the referenced resource.
fn list_thread_attachment_threads(
&self,
_params: ListThreadAttachmentThreadsParams,
) -> ThreadStoreFuture<'_, ThreadAttachmentOwnerPage> {
Box::pin(async {
Err(ThreadStoreError::Unsupported {
operation: "thread/attachmentOwner/list",
})
})
}
/// Removes an attachment and reports whether it existed.
fn remove_thread_attachment(
&self,
@@ -1,5 +1,6 @@
//! Typed parameters for attachment operations on stored threads.
use crate::ThreadAttachmentArchiveFilter;
use codex_protocol::ThreadId;
use serde_json::Value;
@@ -37,3 +38,13 @@ pub struct RemoveThreadAttachmentParams {
/// Stable attachment identity within its thread and type.
pub identity_key: String,
}
/// Parameters for finding all owners of one attachment identity in this store.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListThreadAttachmentThreadsParams {
pub attachment_type: String,
pub identity_key: String,
pub archive_filter: ThreadAttachmentArchiveFilter,
pub cursor: Option<String>,
pub limit: usize,
}
@@ -5169,6 +5169,43 @@ class ThreadAttachmentOperation(Enum):
deleted = "deleted"
class ThreadAttachmentOwner(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
archived: Annotated[
bool,
Field(
description="Whether the owning thread is archived, not whether it is currently executing a turn."
),
]
thread_id: Annotated[str, Field(alias="threadId")]
class ThreadAttachmentOwnerListParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
archived: Annotated[
bool | None,
Field(
description="Omitted or null returns all matches; false returns non-archived threads only."
),
] = None
attachment_type: Annotated[str, Field(alias="attachmentType")]
cursor: str | None = None
identity_key: Annotated[str, Field(alias="identityKey")]
limit: Annotated[int | None, Field(ge=0)] = None
class ThreadAttachmentOwnerListResponse(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
data: list[ThreadAttachmentOwner]
next_cursor: Annotated[str | None, Field(alias="nextCursor")] = None
class ThreadAttachmentRemoveParams(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -6907,6 +6944,18 @@ class ThreadAttachmentListRequest(BaseModel):
params: ThreadAttachmentListParams
class ThreadAttachmentOwnerListRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
)
id: RequestId
method: Annotated[
Literal["thread/attachmentOwner/list"],
Field(title="Thread/attachmentOwner/listRequestMethod"),
]
params: ThreadAttachmentOwnerListParams
class ThreadAttachmentRemoveRequest(BaseModel):
model_config = ConfigDict(
populate_by_name=True,
@@ -12545,6 +12594,7 @@ class ClientRequest(
| ThreadMetadataUpdateRequest
| ThreadAttachmentAddRequest
| ThreadAttachmentListRequest
| ThreadAttachmentOwnerListRequest
| ThreadAttachmentRemoveRequest
| ThreadSectionMoveRequest
| ThreadUnarchiveRequest
@@ -12655,6 +12705,7 @@ class ClientRequest(
| ThreadMetadataUpdateRequest
| ThreadAttachmentAddRequest
| ThreadAttachmentListRequest
| ThreadAttachmentOwnerListRequest
| ThreadAttachmentRemoveRequest
| ThreadSectionMoveRequest
| ThreadUnarchiveRequest