[codex] Record external agent import results (#28396)

## Summary
- restore `externalAgentConfig/import/progress` notifications while
keeping `externalAgentConfig/import/completed` as the must-deliver event
- persist completed external-agent config imports in state DB by
`importId`, including concrete success/failure details for config,
AGENTS.md, skills, plugins, MCP servers, subagents, hooks, commands, and
sessions
- add `externalAgentConfig/import/readHistories` so clients can recover
persisted import results after missing the live completion notification
- include `errorType` on import failures in protocol
responses/notifications and persisted DB JSON so future code can
classify failures without another wire/storage shape change

## Validation
- `git diff --check`
- `just test -p codex-state external_agent_config_imports`
- `just test -p codex-app-server-protocol`
- `CODEX_SQLITE_HOME=/private/tmp/codex-app-server-sqlite-read-details
just test -p codex-app-server
external_agent_config_import_sends_completion_notification_for_sync_only_import`

Also ran earlier broader checks before publishing:
- `just test -p codex-state`
-
`CODEX_SQLITE_HOME=/private/tmp/codex-app-server-external-agent-test-sqlite
just test -p codex-app-server external_agent_config`
- `just test -p codex-external-agent-migration`
This commit is contained in:
charlesgong-openai
2026-06-15 23:17:24 -07:00
committed by GitHub
Unverified
parent 1e015884c5
commit 314fa3d25b
30 changed files with 1370 additions and 101 deletions
@@ -0,0 +1,136 @@
use super::StateRuntime;
use crate::model::datetime_to_epoch_millis;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use sqlx::Row;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalAgentConfigImportSuccessRecord {
pub item_type: String,
pub cwd: Option<PathBuf>,
pub source: Option<String>,
pub target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalAgentConfigImportFailureRecord {
pub item_type: String,
pub error_type: Option<String>,
pub failure_stage: String,
pub message: String,
pub cwd: Option<PathBuf>,
pub source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalAgentConfigImportDetailsRecord {
pub successes: Vec<ExternalAgentConfigImportSuccessRecord>,
pub failures: Vec<ExternalAgentConfigImportFailureRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalAgentConfigImportHistoryRecord {
pub import_id: String,
pub completed_at_ms: i64,
pub successes: Vec<ExternalAgentConfigImportSuccessRecord>,
pub failures: Vec<ExternalAgentConfigImportFailureRecord>,
}
impl StateRuntime {
pub async fn record_external_agent_config_import_completed(
&self,
import_id: &str,
successes: &[ExternalAgentConfigImportSuccessRecord],
failures: &[ExternalAgentConfigImportFailureRecord],
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO external_agent_config_imports (
import_id,
completed_at_ms,
successes,
failures
) VALUES (?, ?, ?, ?)
ON CONFLICT(import_id) DO UPDATE SET
completed_at_ms = excluded.completed_at_ms,
successes = excluded.successes,
failures = excluded.failures
"#,
)
.bind(import_id)
.bind(datetime_to_epoch_millis(Utc::now()))
.bind(serde_json::to_string(successes)?)
.bind(serde_json::to_string(failures)?)
.execute(self.pool.as_ref())
.await?;
Ok(())
}
pub async fn external_agent_config_import_details_record(
&self,
import_id: &str,
) -> anyhow::Result<Option<ExternalAgentConfigImportDetailsRecord>> {
let row = sqlx::query(
r#"
SELECT
successes,
failures
FROM external_agent_config_imports
WHERE import_id = ?
"#,
)
.bind(import_id)
.fetch_optional(self.pool.as_ref())
.await?;
row.map(|row| {
let successes: String = row.try_get("successes")?;
let failures: String = row.try_get("failures")?;
Ok(ExternalAgentConfigImportDetailsRecord {
successes: serde_json::from_str(&successes)?,
failures: serde_json::from_str(&failures)?,
})
})
.transpose()
}
pub async fn external_agent_config_import_history_records(
&self,
) -> anyhow::Result<Vec<ExternalAgentConfigImportHistoryRecord>> {
let rows = sqlx::query(
r#"
SELECT
import_id,
completed_at_ms,
successes,
failures
FROM external_agent_config_imports
ORDER BY completed_at_ms DESC, import_id ASC
"#,
)
.fetch_all(self.pool.as_ref())
.await?;
rows.into_iter()
.map(|row| {
let import_id: String = row.try_get("import_id")?;
let completed_at_ms: i64 = row.try_get("completed_at_ms")?;
let successes: String = row.try_get("successes")?;
let failures: String = row.try_get("failures")?;
Ok(ExternalAgentConfigImportHistoryRecord {
import_id,
completed_at_ms,
successes: serde_json::from_str(&successes)?,
failures: serde_json::from_str(&failures)?,
})
})
.collect()
}
}
#[cfg(test)]
#[path = "external_agent_config_imports_tests.rs"]
mod tests;
@@ -0,0 +1,145 @@
use super::*;
use crate::runtime::test_support::unique_temp_dir;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn records_completion_by_import_id() -> anyhow::Result<()> {
let runtime = StateRuntime::init(unique_temp_dir(), "test-provider".to_string()).await?;
runtime
.record_external_agent_config_import_completed(
"import-1",
&[ExternalAgentConfigImportSuccessRecord {
item_type: "CONFIG".to_string(),
cwd: None,
source: Some("settings.json".to_string()),
target: Some("config.toml".to_string()),
}],
&[],
)
.await?;
runtime
.record_external_agent_config_import_completed(
"import-1",
&[
ExternalAgentConfigImportSuccessRecord {
item_type: "CONFIG".to_string(),
cwd: None,
source: Some("settings.json".to_string()),
target: Some("config.toml".to_string()),
},
ExternalAgentConfigImportSuccessRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
cwd: None,
source: Some("github".to_string()),
target: Some("github".to_string()),
},
],
&[ExternalAgentConfigImportFailureRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
error_type: None,
failure_stage: "import".to_string(),
message: "failed".to_string(),
cwd: None,
source: Some("broken".to_string()),
}],
)
.await?;
assert_eq!(
runtime
.external_agent_config_import_details_record("import-1")
.await?,
Some(ExternalAgentConfigImportDetailsRecord {
successes: vec![
ExternalAgentConfigImportSuccessRecord {
item_type: "CONFIG".to_string(),
cwd: None,
source: Some("settings.json".to_string()),
target: Some("config.toml".to_string()),
},
ExternalAgentConfigImportSuccessRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
cwd: None,
source: Some("github".to_string()),
target: Some("github".to_string()),
}
],
failures: vec![ExternalAgentConfigImportFailureRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
error_type: None,
failure_stage: "import".to_string(),
message: "failed".to_string(),
cwd: None,
source: Some("broken".to_string()),
}],
})
);
assert_eq!(
runtime
.external_agent_config_import_history_records()
.await?
.into_iter()
.map(|record| (
record.import_id,
record.successes,
record.failures,
record.completed_at_ms > 0
))
.collect::<Vec<_>>(),
vec![(
"import-1".to_string(),
vec![
ExternalAgentConfigImportSuccessRecord {
item_type: "CONFIG".to_string(),
cwd: None,
source: Some("settings.json".to_string()),
target: Some("config.toml".to_string()),
},
ExternalAgentConfigImportSuccessRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
cwd: None,
source: Some("github".to_string()),
target: Some("github".to_string()),
}
],
vec![ExternalAgentConfigImportFailureRecord {
item_type: "MCP_SERVER_CONFIG".to_string(),
error_type: None,
failure_stage: "import".to_string(),
message: "failed".to_string(),
cwd: None,
source: Some("broken".to_string()),
}],
true
)]
);
Ok(())
}
#[tokio::test]
async fn reads_all_history_records() -> anyhow::Result<()> {
let runtime = StateRuntime::init(unique_temp_dir(), "test-provider".to_string()).await?;
runtime
.record_external_agent_config_import_completed("import-1", &[], &[])
.await?;
runtime
.record_external_agent_config_import_completed("import-2", &[], &[])
.await?;
let mut records = runtime
.external_agent_config_import_history_records()
.await?;
records.sort_by(|left, right| left.import_id.cmp(&right.import_id));
assert_eq!(
records
.into_iter()
.map(|record| record.import_id)
.collect::<Vec<_>>(),
vec!["import-1".to_string(), "import-2".to_string()]
);
Ok(())
}