mirror of
https://github.com/openai/codex.git
synced 2026-05-16 09:12:54 +00:00
## Why Feedback uploads already carry auth-derived context like `chatgpt_user_id`, but they do not include the authenticated workspace/account id. Adding `account_id` makes feedback triage easier when a user can operate across multiple ChatGPT workspaces. ## What changed - emit auth-derived `account_id` into feedback tags in `app-server` before the feedback snapshot is uploaded - preserve that tag through `codex-feedback` upload tag assembly alongside the existing merge behavior for other tags - extend `codex-feedback` coverage to assert that snapshot-derived `account_id` is present in uploaded tags ## Verification - `cargo test -p codex-feedback upload_tags_include_client_tags_and_preserve_reserved_fields` - `cargo test -p codex-app-server --lib feedback_processor`
253 lines
9.2 KiB
Rust
253 lines
9.2 KiB
Rust
use super::*;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct FeedbackRequestProcessor {
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
config: Arc<Config>,
|
|
feedback: CodexFeedback,
|
|
log_db: Option<LogDbLayer>,
|
|
state_db: Option<StateDbHandle>,
|
|
}
|
|
|
|
impl FeedbackRequestProcessor {
|
|
pub(crate) fn new(
|
|
auth_manager: Arc<AuthManager>,
|
|
thread_manager: Arc<ThreadManager>,
|
|
config: Arc<Config>,
|
|
feedback: CodexFeedback,
|
|
log_db: Option<LogDbLayer>,
|
|
state_db: Option<StateDbHandle>,
|
|
) -> Self {
|
|
Self {
|
|
auth_manager,
|
|
thread_manager,
|
|
config,
|
|
feedback,
|
|
log_db,
|
|
state_db,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn feedback_upload(
|
|
&self,
|
|
params: FeedbackUploadParams,
|
|
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
|
self.upload_feedback_response(params)
|
|
.await
|
|
.map(|response| Some(response.into()))
|
|
}
|
|
|
|
async fn upload_feedback_response(
|
|
&self,
|
|
params: FeedbackUploadParams,
|
|
) -> Result<FeedbackUploadResponse, JSONRPCErrorError> {
|
|
if !self.config.feedback_enabled {
|
|
return Err(invalid_request(
|
|
"sending feedback is disabled by configuration",
|
|
));
|
|
}
|
|
|
|
let FeedbackUploadParams {
|
|
classification,
|
|
reason,
|
|
thread_id,
|
|
include_logs,
|
|
extra_log_files,
|
|
tags,
|
|
} = params;
|
|
|
|
let conversation_id = match thread_id.as_deref() {
|
|
Some(thread_id) => match ThreadId::from_string(thread_id) {
|
|
Ok(conversation_id) => Some(conversation_id),
|
|
Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))),
|
|
},
|
|
None => None,
|
|
};
|
|
|
|
if let Some(chatgpt_user_id) = self
|
|
.auth_manager
|
|
.auth_cached()
|
|
.and_then(|auth| auth.get_chatgpt_user_id())
|
|
{
|
|
tracing::info!(target: "feedback_tags", chatgpt_user_id);
|
|
}
|
|
if let Some(account_id) = self
|
|
.auth_manager
|
|
.auth_cached()
|
|
.and_then(|auth| auth.get_account_id())
|
|
{
|
|
tracing::info!(target: "feedback_tags", account_id);
|
|
}
|
|
let snapshot = self.feedback.snapshot(conversation_id);
|
|
let thread_id = snapshot.thread_id.clone();
|
|
let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs {
|
|
if let Some(log_db) = self.log_db.as_ref() {
|
|
log_db.flush().await;
|
|
}
|
|
let state_db_ctx = self.state_db.clone();
|
|
let feedback_thread_ids = match conversation_id {
|
|
Some(conversation_id) => match self
|
|
.thread_manager
|
|
.list_agent_subtree_thread_ids(conversation_id)
|
|
.await
|
|
{
|
|
Ok(thread_ids) => thread_ids,
|
|
Err(err) => {
|
|
warn!(
|
|
"failed to list feedback subtree for thread_id={conversation_id}: {err}"
|
|
);
|
|
let mut thread_ids = vec![conversation_id];
|
|
if let Some(state_db_ctx) = state_db_ctx.as_ref() {
|
|
for status in [
|
|
codex_state::DirectionalThreadSpawnEdgeStatus::Open,
|
|
codex_state::DirectionalThreadSpawnEdgeStatus::Closed,
|
|
] {
|
|
match state_db_ctx
|
|
.list_thread_spawn_descendants_with_status(
|
|
conversation_id,
|
|
status,
|
|
)
|
|
.await
|
|
{
|
|
Ok(descendant_ids) => thread_ids.extend(descendant_ids),
|
|
Err(err) => warn!(
|
|
"failed to list persisted feedback subtree for thread_id={conversation_id}: {err}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
thread_ids
|
|
}
|
|
},
|
|
None => Vec::new(),
|
|
};
|
|
let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref()
|
|
&& !feedback_thread_ids.is_empty()
|
|
{
|
|
let thread_id_texts = feedback_thread_ids
|
|
.iter()
|
|
.map(ToString::to_string)
|
|
.collect::<Vec<_>>();
|
|
let thread_id_refs = thread_id_texts
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<Vec<_>>();
|
|
match state_db_ctx
|
|
.query_feedback_logs_for_threads(&thread_id_refs)
|
|
.await
|
|
{
|
|
Ok(logs) if logs.is_empty() => None,
|
|
Ok(logs) => Some(logs),
|
|
Err(err) => {
|
|
let thread_ids = thread_id_texts.join(", ");
|
|
warn!(
|
|
"failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
(feedback_thread_ids, sqlite_feedback_logs, state_db_ctx)
|
|
} else {
|
|
(Vec::new(), None, None)
|
|
};
|
|
|
|
let mut attachment_paths = Vec::new();
|
|
let mut seen_attachment_paths = HashSet::new();
|
|
if include_logs {
|
|
for feedback_thread_id in &feedback_thread_ids {
|
|
let Some(rollout_path) = self
|
|
.resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref())
|
|
.await
|
|
else {
|
|
continue;
|
|
};
|
|
if seen_attachment_paths.insert(rollout_path.clone()) {
|
|
attachment_paths.push(FeedbackAttachmentPath {
|
|
path: rollout_path,
|
|
attachment_filename_override: None,
|
|
});
|
|
}
|
|
}
|
|
if let Some(conversation_id) = conversation_id
|
|
&& let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
|
|
&& let Some(guardian_rollout_path) =
|
|
conversation.guardian_trunk_rollout_path().await
|
|
&& seen_attachment_paths.insert(guardian_rollout_path.clone())
|
|
{
|
|
attachment_paths.push(FeedbackAttachmentPath {
|
|
path: guardian_rollout_path,
|
|
attachment_filename_override: Some(auto_review_rollout_filename(
|
|
conversation_id,
|
|
)),
|
|
});
|
|
}
|
|
}
|
|
if let Some(extra_log_files) = extra_log_files {
|
|
for extra_log_file in extra_log_files {
|
|
if seen_attachment_paths.insert(extra_log_file.clone()) {
|
|
attachment_paths.push(FeedbackAttachmentPath {
|
|
path: extra_log_file,
|
|
attachment_filename_override: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let session_source = self.thread_manager.session_source();
|
|
|
|
let upload_result = tokio::task::spawn_blocking(move || {
|
|
snapshot.upload_feedback(FeedbackUploadOptions {
|
|
classification: &classification,
|
|
reason: reason.as_deref(),
|
|
tags: tags.as_ref(),
|
|
include_logs,
|
|
extra_attachment_paths: &attachment_paths,
|
|
session_source: Some(session_source),
|
|
logs_override: sqlite_feedback_logs,
|
|
})
|
|
})
|
|
.await;
|
|
|
|
let upload_result = match upload_result {
|
|
Ok(result) => result,
|
|
Err(join_err) => {
|
|
return Err(internal_error(format!(
|
|
"failed to upload feedback: {join_err}"
|
|
)));
|
|
}
|
|
};
|
|
|
|
upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?;
|
|
Ok(FeedbackUploadResponse { thread_id })
|
|
}
|
|
|
|
async fn resolve_rollout_path(
|
|
&self,
|
|
conversation_id: ThreadId,
|
|
state_db_ctx: Option<&StateDbHandle>,
|
|
) -> Option<PathBuf> {
|
|
if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
|
|
&& let Some(rollout_path) = conversation.rollout_path()
|
|
{
|
|
return Some(rollout_path);
|
|
}
|
|
|
|
let state_db_ctx = state_db_ctx?;
|
|
state_db_ctx
|
|
.find_rollout_path_by_id(conversation_id, /*archived_only*/ None)
|
|
.await
|
|
.unwrap_or_else(|err| {
|
|
warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}");
|
|
None
|
|
})
|
|
}
|
|
}
|
|
|
|
fn auto_review_rollout_filename(thread_id: ThreadId) -> String {
|
|
format!("auto-review-rollout-{thread_id}.jsonl")
|
|
}
|