[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
@@ -6620,6 +6620,29 @@
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import/readHistories"
],
"title": "ExternalAgentConfig/import/readHistoriesRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "ExternalAgentConfig/import/readHistoriesRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1167,6 +1167,12 @@
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
@@ -1219,6 +1225,24 @@
],
"type": "object"
},
"ExternalAgentConfigImportProgressNotification": {
"properties": {
"importId": {
"type": "string"
},
"itemTypeResults": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportTypeResult"
},
"type": "array"
}
},
"required": [
"importId",
"itemTypeResults"
],
"type": "object"
},
"ExternalAgentConfigImportTypeResult": {
"properties": {
"failures": {
@@ -6333,6 +6357,26 @@
"title": "RemoteControl/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"externalAgentConfig/import/progress"
],
"title": "ExternalAgentConfig/import/progressNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigImportProgressNotification"
}
},
"required": [
"method",
"params"
],
"title": "ExternalAgentConfig/import/progressNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -2124,6 +2124,29 @@
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import/readHistories"
],
"title": "ExternalAgentConfig/import/readHistoriesRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "ExternalAgentConfig/import/readHistoriesRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4834,6 +4857,26 @@
"title": "RemoteControl/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"externalAgentConfig/import/progress"
],
"title": "ExternalAgentConfig/import/progressNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportProgressNotification"
}
},
"required": [
"method",
"params"
],
"title": "ExternalAgentConfig/import/progressNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -9089,6 +9132,52 @@
"title": "ExternalAgentConfigImportCompletedNotification",
"type": "object"
},
"ExternalAgentConfigImportHistoriesReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportHistory"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "ExternalAgentConfigImportHistoriesReadResponse",
"type": "object"
},
"ExternalAgentConfigImportHistory": {
"properties": {
"completedAtMs": {
"format": "int64",
"type": "integer"
},
"failures": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportItemTypeFailure"
},
"type": "array"
},
"importId": {
"type": "string"
},
"successes": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportItemTypeSuccess"
},
"type": "array"
}
},
"required": [
"completedAtMs",
"failures",
"importId",
"successes"
],
"type": "object"
},
"ExternalAgentConfigImportItemTypeFailure": {
"properties": {
"cwd": {
@@ -9097,6 +9186,12 @@
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
@@ -9165,6 +9260,26 @@
"title": "ExternalAgentConfigImportParams",
"type": "object"
},
"ExternalAgentConfigImportProgressNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"importId": {
"type": "string"
},
"itemTypeResults": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportTypeResult"
},
"type": "array"
}
},
"required": [
"importId",
"itemTypeResults"
],
"title": "ExternalAgentConfigImportProgressNotification",
"type": "object"
},
"ExternalAgentConfigImportResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -3111,6 +3111,29 @@
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import/readHistories"
],
"title": "ExternalAgentConfig/import/readHistoriesRequestMethod",
"type": "string"
},
"params": {
"type": "null"
}
},
"required": [
"id",
"method"
],
"title": "ExternalAgentConfig/import/readHistoriesRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -5402,6 +5425,52 @@
"title": "ExternalAgentConfigImportCompletedNotification",
"type": "object"
},
"ExternalAgentConfigImportHistoriesReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportHistory"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "ExternalAgentConfigImportHistoriesReadResponse",
"type": "object"
},
"ExternalAgentConfigImportHistory": {
"properties": {
"completedAtMs": {
"format": "int64",
"type": "integer"
},
"failures": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure"
},
"type": "array"
},
"importId": {
"type": "string"
},
"successes": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess"
},
"type": "array"
}
},
"required": [
"completedAtMs",
"failures",
"importId",
"successes"
],
"type": "object"
},
"ExternalAgentConfigImportItemTypeFailure": {
"properties": {
"cwd": {
@@ -5410,6 +5479,12 @@
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
@@ -5478,6 +5553,26 @@
"title": "ExternalAgentConfigImportParams",
"type": "object"
},
"ExternalAgentConfigImportProgressNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"importId": {
"type": "string"
},
"itemTypeResults": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportTypeResult"
},
"type": "array"
}
},
"required": [
"importId",
"itemTypeResults"
],
"title": "ExternalAgentConfigImportProgressNotification",
"type": "object"
},
"ExternalAgentConfigImportResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13011,6 +13106,26 @@
"title": "RemoteControl/status/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"externalAgentConfig/import/progress"
],
"title": "ExternalAgentConfig/import/progressNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigImportProgressNotification"
}
},
"required": [
"method",
"params"
],
"title": "ExternalAgentConfig/import/progressNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -9,6 +9,12 @@
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
@@ -0,0 +1,128 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigImportHistory": {
"properties": {
"completedAtMs": {
"format": "int64",
"type": "integer"
},
"failures": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure"
},
"type": "array"
},
"importId": {
"type": "string"
},
"successes": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess"
},
"type": "array"
}
},
"required": [
"completedAtMs",
"failures",
"importId",
"successes"
],
"type": "object"
},
"ExternalAgentConfigImportItemTypeFailure": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
},
"message": {
"type": "string"
},
"source": {
"type": [
"string",
"null"
]
}
},
"required": [
"failureStage",
"itemType",
"message"
],
"type": "object"
},
"ExternalAgentConfigImportItemTypeSuccess": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
},
"source": {
"type": [
"string",
"null"
]
},
"target": {
"type": [
"string",
"null"
]
}
},
"required": [
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportHistory"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "ExternalAgentConfigImportHistoriesReadResponse",
"type": "object"
}
@@ -0,0 +1,127 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigImportItemTypeFailure": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"errorType": {
"type": [
"string",
"null"
]
},
"failureStage": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
},
"message": {
"type": "string"
},
"source": {
"type": [
"string",
"null"
]
}
},
"required": [
"failureStage",
"itemType",
"message"
],
"type": "object"
},
"ExternalAgentConfigImportItemTypeSuccess": {
"properties": {
"cwd": {
"type": [
"string",
"null"
]
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
},
"source": {
"type": [
"string",
"null"
]
},
"target": {
"type": [
"string",
"null"
]
}
},
"required": [
"itemType"
],
"type": "object"
},
"ExternalAgentConfigImportTypeResult": {
"properties": {
"failures": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure"
},
"type": "array"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
},
"successes": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess"
},
"type": "array"
}
},
"required": [
"failures",
"itemType",
"successes"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"PLUGINS",
"MCP_SERVER_CONFIG",
"SUBAGENTS",
"HOOKS",
"COMMANDS",
"SESSIONS"
],
"type": "string"
}
},
"properties": {
"importId": {
"type": "string"
},
"itemTypeResults": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigImportTypeResult"
},
"type": "array"
}
},
"required": [
"importId",
"itemTypeResults"
],
"title": "ExternalAgentConfigImportProgressNotification",
"type": "object"
}
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 { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory";
export type ExternalAgentConfigImportHistoriesReadResponse = { data: Array<ExternalAgentConfigImportHistory>, };
@@ -0,0 +1,7 @@
// 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 { ExternalAgentConfigImportItemTypeFailure } from "./ExternalAgentConfigImportItemTypeFailure";
import type { ExternalAgentConfigImportItemTypeSuccess } from "./ExternalAgentConfigImportItemTypeSuccess";
export type ExternalAgentConfigImportHistory = { importId: string, completedAtMs: bigint, successes: Array<ExternalAgentConfigImportItemTypeSuccess>, failures: Array<ExternalAgentConfigImportItemTypeFailure>, };
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, failureStage: string, message: string, cwd: string | null, source: string | null, };
export type ExternalAgentConfigImportItemTypeFailure = { itemType: ExternalAgentConfigMigrationItemType, errorType: string | null, failureStage: string, message: string, cwd: string | null, source: string | null, };
@@ -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 { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult";
export type ExternalAgentConfigImportProgressNotification = { importId: string, itemTypeResults: Array<ExternalAgentConfigImportTypeResult>, };
@@ -110,9 +110,12 @@ export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams";
export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse";
export type { ExternalAgentConfigImportCompletedNotification } from "./ExternalAgentConfigImportCompletedNotification";
export type { ExternalAgentConfigImportHistoriesReadResponse } from "./ExternalAgentConfigImportHistoriesReadResponse";
export type { ExternalAgentConfigImportHistory } from "./ExternalAgentConfigImportHistory";
export type { ExternalAgentConfigImportItemTypeFailure } from "./ExternalAgentConfigImportItemTypeFailure";
export type { ExternalAgentConfigImportItemTypeSuccess } from "./ExternalAgentConfigImportItemTypeSuccess";
export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImportParams";
export type { ExternalAgentConfigImportProgressNotification } from "./ExternalAgentConfigImportProgressNotification";
export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse";
export type { ExternalAgentConfigImportTypeResult } from "./ExternalAgentConfigImportTypeResult";
export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
@@ -1103,6 +1103,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::ExternalAgentConfigImportResponse,
},
ExternalAgentConfigImportHistoriesRead => "externalAgentConfig/import/readHistories" {
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
serialization: global_shared_read("config"),
response: v2::ExternalAgentConfigImportHistoriesReadResponse,
},
ConfigValueWrite => "config/value/write" {
params: v2::ConfigValueWriteParams,
serialization: global("config"),
@@ -1614,6 +1619,7 @@ server_notification_definitions! {
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification),
ExternalAgentConfigImportProgress => "externalAgentConfig/import/progress" (v2::ExternalAgentConfigImportProgressNotification),
ExternalAgentConfigImportCompleted => "externalAgentConfig/import/completed" (v2::ExternalAgentConfigImportCompletedNotification),
FsChanged => "fs/changed" (v2::FsChangedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
@@ -677,6 +677,7 @@ pub struct ExternalAgentConfigImportResponse {
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportItemTypeFailure {
pub item_type: ExternalAgentConfigMigrationItemType,
pub error_type: Option<String>,
pub failure_stage: String,
pub message: String,
pub cwd: Option<PathBuf>,
@@ -702,6 +703,31 @@ pub struct ExternalAgentConfigImportTypeResult {
pub failures: Vec<ExternalAgentConfigImportItemTypeFailure>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportHistory {
pub import_id: String,
pub completed_at_ms: i64,
pub successes: Vec<ExternalAgentConfigImportItemTypeSuccess>,
pub failures: Vec<ExternalAgentConfigImportItemTypeFailure>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportHistoriesReadResponse {
pub data: Vec<ExternalAgentConfigImportHistory>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportProgressNotification {
pub import_id: String,
pub item_type_results: Vec<ExternalAgentConfigImportTypeResult>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -129,11 +129,6 @@ impl ExternalAgentConfigImportItemResult {
}
}
pub(crate) fn record_successes(&mut self, count: usize) {
let count = u32::try_from(count).unwrap_or(u32::MAX);
self.success_count = self.success_count.saturating_add(count);
}
pub(crate) fn record_error(&mut self, raw_error: ExternalAgentConfigImportRawError) {
self.error_count = self.error_count.saturating_add(1);
self.raw_errors.push(raw_error);
@@ -161,6 +156,7 @@ pub(crate) struct ExternalAgentConfigImportSuccess {
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExternalAgentConfigImportRawError {
pub item_type: ExternalAgentConfigMigrationItemType,
pub error_type: Option<String>,
pub failure_stage: String,
pub message: String,
pub cwd: Option<PathBuf>,
@@ -254,33 +250,41 @@ impl ExternalAgentConfigService {
);
let import_result = match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => (|| {
let migrated_count = self.import_config(migration_item.cwd.as_deref())?;
if let Some((source, target)) =
self.import_config(migration_item.cwd.as_deref())?
{
item_result.record_success(Some(source), Some(target));
}
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Config,
/*skills_count*/ None,
);
item_result.record_successes(migrated_count);
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Skills => (|| {
let skills_count = self.import_skills(migration_item.cwd.as_deref())?;
let imported_skills = self.import_skills(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Skills,
Some(skills_count),
Some(imported_skills.len()),
);
item_result.record_successes(skills_count);
for skill_name in imported_skills {
item_result.record_success(Some(skill_name.clone()), Some(skill_name));
}
Ok(())
})(),
ExternalAgentConfigMigrationItemType::AgentsMd => (|| {
let migrated_count = self.import_agents_md(migration_item.cwd.as_deref())?;
if let Some((source, target)) =
self.import_agents_md(migration_item.cwd.as_deref())?
{
item_result.record_success(Some(source), Some(target));
}
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::AgentsMd,
/*skills_count*/ None,
);
item_result.record_successes(migrated_count);
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Plugins => {
@@ -357,44 +361,54 @@ impl ExternalAgentConfigService {
.await
}
ExternalAgentConfigMigrationItemType::McpServerConfig => (|| {
let migrated_count =
let migrated_server_names =
self.import_mcp_server_config(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::McpServerConfig,
/*skills_count*/ None,
);
item_result.record_successes(migrated_count);
for server_name in migrated_server_names {
item_result.record_success(Some(server_name.clone()), Some(server_name));
}
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Subagents => (|| {
let subagents_count = self.import_subagents(migration_item.cwd.as_deref())?;
let imported_subagents =
self.import_subagents(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Subagents,
Some(subagents_count),
Some(imported_subagents.len()),
);
item_result.record_successes(subagents_count);
for subagent_name in imported_subagents {
item_result
.record_success(Some(subagent_name.clone()), Some(subagent_name));
}
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Hooks => (|| {
let migrated_count = self.import_hooks(migration_item.cwd.as_deref())?;
let migrated_hook_names = self.import_hooks(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Hooks,
/*skills_count*/ None,
);
item_result.record_successes(migrated_count);
for hook_name in migrated_hook_names {
item_result.record_success(Some(hook_name.clone()), Some(hook_name));
}
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Commands => (|| {
let commands_count = self.import_commands(migration_item.cwd.as_deref())?;
let imported_commands = self.import_commands(migration_item.cwd.as_deref())?;
emit_migration_metric(
EXTERNAL_AGENT_CONFIG_IMPORT_METRIC,
ExternalAgentConfigMigrationItemType::Commands,
Some(commands_count),
Some(imported_commands.len()),
);
item_result.record_successes(commands_count);
for command_name in imported_commands {
item_result.record_success(Some(command_name.clone()), Some(command_name));
}
Ok(())
})(),
ExternalAgentConfigMigrationItemType::Sessions => Ok(()),
@@ -957,7 +971,7 @@ impl ExternalAgentConfigService {
Ok(outcome)
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
@@ -965,7 +979,7 @@ impl ExternalAgentConfigService {
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(None);
} else {
(
self.external_agent_home.join("settings.json"),
@@ -973,11 +987,11 @@ impl ExternalAgentConfigService {
)
};
let Some(settings) = effective_external_settings(&source_settings)? else {
return Ok(0);
return Ok(None);
};
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(0);
return Ok(None);
}
let Some(target_parent) = target_config.parent() else {
@@ -986,7 +1000,10 @@ impl ExternalAgentConfigService {
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
write_toml_file(&target_config, &migrated)?;
return Ok(1);
return Ok(Some((
source_settings.display().to_string(),
target_config.display().to_string(),
)));
}
let existing_raw = fs::read_to_string(&target_config)?;
@@ -999,14 +1016,17 @@ impl ExternalAgentConfigService {
let changed = merge_missing_toml_values(&mut existing, &migrated)?;
if !changed {
return Ok(0);
return Ok(None);
}
write_toml_file(&target_config, &existing)?;
Ok(1)
Ok(Some((
source_settings.display().to_string(),
target_config.display().to_string(),
)))
}
fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_mcp_server_config(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let repo_root = find_repo_root(cwd)?;
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
(
@@ -1014,7 +1034,7 @@ impl ExternalAgentConfigService {
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(Vec::new());
} else {
(
self.external_agent_home.join("settings.json"),
@@ -1031,7 +1051,7 @@ impl ExternalAgentConfigService {
settings.as_ref(),
)?;
if is_empty_toml_table(&migrated) {
return Ok(0);
return Ok(Vec::new());
}
let Some(target_parent) = target_config.parent() else {
@@ -1039,9 +1059,9 @@ impl ExternalAgentConfigService {
};
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
let migrated_count = migrated_mcp_server_names(&migrated).len();
let migrated_server_names = migrated_mcp_server_names(&migrated);
write_toml_file(&target_config, &migrated)?;
return Ok(migrated_count);
return Ok(migrated_server_names);
}
let existing_raw = fs::read_to_string(&target_config)?;
@@ -1051,21 +1071,21 @@ impl ExternalAgentConfigService {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
let merged_server_count = merge_missing_mcp_servers(&mut existing, &migrated)?.len();
if merged_server_count > 0 {
let merged_server_names = merge_missing_mcp_servers(&mut existing, &migrated)?;
if !merged_server_names.is_empty() {
write_toml_file(&target_config, &existing)?;
}
Ok(merged_server_count)
Ok(merged_server_names)
}
fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_subagents(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_agents, target_agents) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("agents"),
repo_root.join(".codex").join("agents"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(Vec::new());
} else {
(
self.external_agent_home.join("agents"),
@@ -1076,7 +1096,7 @@ impl ExternalAgentConfigService {
import_subagents(&source_agents, &target_agents)
}
fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_hooks(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_external_agent_dir, target_hooks) =
if let Some(repo_root) = find_repo_root(cwd)? {
(
@@ -1084,7 +1104,7 @@ impl ExternalAgentConfigService {
repo_root.join(".codex").join("hooks.json"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(Vec::new());
} else {
(
self.external_agent_home.clone(),
@@ -1092,20 +1112,22 @@ impl ExternalAgentConfigService {
)
};
Ok(usize::from(import_hooks(
&source_external_agent_dir,
&target_hooks,
)?))
let hook_names = hook_migration_event_names(&source_external_agent_dir, &target_hooks)?;
if import_hooks(&source_external_agent_dir, &target_hooks)? {
Ok(hook_names)
} else {
Ok(Vec::new())
}
}
fn import_commands(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_commands(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_commands, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("commands"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(Vec::new());
} else {
(
self.external_agent_home.join("commands"),
@@ -1116,14 +1138,14 @@ impl ExternalAgentConfigService {
import_commands(&source_commands, &target_skills)
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<Vec<String>> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(Vec::new());
} else {
(
self.external_agent_home.join("skills"),
@@ -1131,11 +1153,11 @@ impl ExternalAgentConfigService {
)
};
if !source_skills.is_dir() {
return Ok(0);
return Ok(Vec::new());
}
fs::create_dir_all(&target_skills)?;
let mut copied_count = 0usize;
let mut copied_names = Vec::new();
for entry in fs::read_dir(&source_skills)? {
let entry = entry?;
@@ -1150,20 +1172,20 @@ impl ExternalAgentConfigService {
}
copy_dir_recursive(&entry.path(), &target)?;
copied_count += 1;
copied_names.push(entry.file_name().to_string_lossy().to_string());
}
Ok(copied_count)
Ok(copied_names)
}
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<usize> {
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {
let Some(source_agents_md) = find_repo_agents_md_source(&repo_root)? else {
return Ok(0);
return Ok(None);
};
(source_agents_md, repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(0);
return Ok(None);
} else {
(
self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
@@ -1173,7 +1195,7 @@ impl ExternalAgentConfigService {
if !is_non_empty_text_file(&source_agents_md)?
|| !is_missing_or_empty_text_file(&target_agents_md)?
{
return Ok(0);
return Ok(None);
}
let Some(target_parent) = target_agents_md.parent() else {
@@ -1182,7 +1204,10 @@ impl ExternalAgentConfigService {
fs::create_dir_all(target_parent)?;
rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)?;
Ok(1)
Ok(Some((
source_agents_md.display().to_string(),
target_agents_md.display().to_string(),
)))
}
}
@@ -1826,6 +1851,7 @@ pub(crate) fn record_import_error(
) {
result.record_error(ExternalAgentConfigImportRawError {
item_type: result.item_type,
error_type: None,
failure_stage: failure_stage.to_string(),
message: message.into(),
cwd: result.cwd.clone(),
@@ -1856,6 +1882,7 @@ fn plugin_import_raw_error(
) -> ExternalAgentConfigImportRawError {
ExternalAgentConfigImportRawError {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
error_type: None,
failure_stage: failure_stage.to_string(),
message,
cwd: cwd.map(Path::to_path_buf),
@@ -47,11 +47,26 @@ fn assert_single_plugin_raw_error(
ExternalAgentConfigMigrationItemType::Plugins
);
assert_eq!(raw_error.failure_stage, failure_stage);
assert_eq!(raw_error.error_type, None);
assert_eq!(raw_error.cwd, None);
assert_eq!(raw_error.source.as_deref(), Some(source));
assert!(!raw_error.message.is_empty());
}
fn import_success(
item_type: ExternalAgentConfigMigrationItemType,
cwd: Option<PathBuf>,
source: impl Into<String>,
target: impl Into<String>,
) -> ExternalAgentConfigImportSuccess {
ExternalAgentConfigImportSuccess {
item_type,
cwd,
source: Some(source.into()),
target: Some(target.into()),
}
}
#[tokio::test]
async fn detect_home_lists_config_skills_and_agents_md() {
let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -1232,7 +1247,15 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: Vec::new(),
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::AgentsMd,
Some(repo_root.clone()),
repo_root
.join(EXTERNAL_AGENT_CONFIG_MD)
.display()
.to_string(),
repo_root.join("AGENTS.md").display().to_string(),
)],
raw_errors: Vec::new(),
},
ExternalAgentConfigImportItemResult {
@@ -1290,7 +1313,15 @@ async fn import_repo_agents_md_overwrites_empty_targets() {
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: Vec::new(),
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::AgentsMd,
Some(repo_root.clone()),
repo_root
.join(EXTERNAL_AGENT_CONFIG_MD)
.display()
.to_string(),
repo_root.join("AGENTS.md").display().to_string(),
)],
raw_errors: Vec::new(),
}]
);
@@ -1383,7 +1414,12 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() {
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: Vec::new(),
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::Hooks,
Some(repo_root.clone()),
"Stop",
"Stop",
)],
raw_errors: Vec::new(),
}]
);
@@ -1456,7 +1492,12 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing()
cwd: Some(repo_root.clone()),
success_count: 1,
error_count: 0,
successes: Vec::new(),
successes: vec![import_success(
ExternalAgentConfigMigrationItemType::McpServerConfig,
Some(repo_root.clone()),
"allowed",
"allowed",
)],
raw_errors: Vec::new(),
}]
);
@@ -2745,7 +2786,7 @@ async fn import_plugins_supports_project_relative_external_agent_plugin_marketpl
}
#[test]
fn import_skills_returns_only_new_skill_directory_count() {
fn import_skills_returns_only_new_skill_directory_names() {
let (_root, external_agent_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
@@ -2757,9 +2798,9 @@ fn import_skills_returns_only_new_skill_directory_count() {
.expect("create source b");
fs::create_dir_all(agents_skills.join("skill-a")).expect("create existing target");
let copied_count = service_for_paths(external_agent_home, codex_home)
let copied_names = service_for_paths(external_agent_home, codex_home)
.import_skills(/*cwd*/ None)
.expect("import skills");
assert_eq!(copied_count, 1);
assert_eq!(copied_names, vec!["skill-b".to_string()]);
}
+18 -10
View File
@@ -24,6 +24,7 @@ use crate::request_processors::CommandExecRequestProcessor;
use crate::request_processors::ConfigRequestProcessor;
use crate::request_processors::EnvironmentRequestProcessor;
use crate::request_processors::ExternalAgentConfigRequestProcessor;
use crate::request_processors::ExternalAgentConfigRequestProcessorArgs;
use crate::request_processors::FeedbackRequestProcessor;
use crate::request_processors::FsRequestProcessor;
use crate::request_processors::GitRequestProcessor;
@@ -475,7 +476,7 @@ impl MessageProcessor {
thread_watch_manager.clone(),
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db,
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
);
@@ -511,15 +512,17 @@ impl MessageProcessor {
thread_manager.clone(),
analytics_events_client,
);
let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(
outgoing.clone(),
Arc::clone(&thread_manager),
Arc::clone(&thread_store),
config_manager.clone(),
config_processor.clone(),
arg0_paths,
config.codex_home.to_path_buf(),
);
let external_agent_config_processor =
ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs {
outgoing: outgoing.clone(),
thread_manager: Arc::clone(&thread_manager),
thread_store: Arc::clone(&thread_store),
config_manager: config_manager.clone(),
config_processor: config_processor.clone(),
state_db,
arg0_paths,
codex_home: config.codex_home.to_path_buf(),
});
let environment_processor =
EnvironmentRequestProcessor::new(thread_manager.environment_manager());
let fs_processor = FsRequestProcessor::new(
@@ -947,6 +950,11 @@ impl MessageProcessor {
.import(request_id.clone(), params)
.await
.map(|()| None),
ClientRequest::ExternalAgentConfigImportHistoriesRead { .. } => self
.external_agent_config_processor
.read_import_histories()
.await
.map(|response| Some(response.into())),
ClientRequest::ConfigValueWrite { params, .. } => {
self.config_processor.value_write(params).await.map(Some)
}
@@ -506,6 +506,7 @@ pub(crate) use command_exec_processor::CommandExecRequestProcessor;
pub(crate) use config_processor::ConfigRequestProcessor;
pub(crate) use environment_processor::EnvironmentRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessorArgs;
pub(crate) use feedback_processor::FeedbackRequestProcessor;
pub(crate) use fs_processor::FsRequestProcessor;
pub(crate) use git_processor::GitRequestProcessor;
@@ -19,9 +19,12 @@ use codex_app_server_protocol::CommandMigration;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportHistory;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeFailure as ProtocolImportFailure;
use codex_app_server_protocol::ExternalAgentConfigImportItemTypeSuccess as ProtocolImportSuccess;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigImportTypeResult as ProtocolImportTypeResult;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
@@ -35,6 +38,9 @@ use codex_app_server_protocol::ServerNotification;
use codex_arg0::Arg0DispatchPaths;
use codex_core::ThreadManager;
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_rollout::StateDbHandle;
use codex_state::ExternalAgentConfigImportFailureRecord;
use codex_state::ExternalAgentConfigImportSuccessRecord;
use codex_thread_store::ThreadStore;
use std::collections::HashSet;
use std::path::PathBuf;
@@ -50,18 +56,32 @@ pub(crate) struct ExternalAgentConfigRequestProcessor {
session_importer: ExternalAgentSessionImporter,
thread_manager: Arc<ThreadManager>,
config_processor: ConfigRequestProcessor,
state_db: Option<StateDbHandle>,
}
pub(crate) struct ExternalAgentConfigRequestProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) thread_manager: Arc<ThreadManager>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) config_manager: ConfigManager,
pub(crate) config_processor: ConfigRequestProcessor,
pub(crate) state_db: Option<StateDbHandle>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) codex_home: PathBuf,
}
impl ExternalAgentConfigRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
thread_manager: Arc<ThreadManager>,
thread_store: Arc<dyn ThreadStore>,
config_manager: ConfigManager,
config_processor: ConfigRequestProcessor,
arg0_paths: Arg0DispatchPaths,
codex_home: PathBuf,
) -> Self {
pub(crate) fn new(args: ExternalAgentConfigRequestProcessorArgs) -> Self {
let ExternalAgentConfigRequestProcessorArgs {
outgoing,
thread_manager,
thread_store,
config_manager,
config_processor,
state_db,
arg0_paths,
codex_home,
} = args;
let session_importer = ExternalAgentSessionImporter::new(
codex_home.clone(),
Arc::clone(&thread_manager),
@@ -75,6 +95,7 @@ impl ExternalAgentConfigRequestProcessor {
session_importer,
thread_manager,
config_processor,
state_db,
}
}
@@ -207,23 +228,31 @@ impl ExternalAgentConfigRequestProcessor {
let mut completed_item_results = Vec::new();
if let Some(session_validation_result) = session_validation_result {
send_import_progress(&self.outgoing, &import_id, &session_validation_result).await;
completed_item_results.push(session_validation_result);
}
for item_result in import_outcome.item_results {
send_import_progress(&self.outgoing, &import_id, &item_result).await;
completed_item_results.push(item_result);
}
let has_background_imports = !import_outcome.pending_plugin_imports.is_empty()
|| !pending_session_imports.is_empty();
if !has_background_imports {
send_completed_import_notification(&self.outgoing, import_id, &completed_item_results)
.await;
send_completed_import_notification(
&self.outgoing,
self.state_db.as_ref(),
import_id,
&completed_item_results,
)
.await;
return Ok(());
}
let session_importer = self.session_importer.clone();
let plugin_processor = self.clone();
let outgoing = Arc::clone(&self.outgoing);
let state_db = self.state_db.clone();
let thread_manager = Arc::clone(&self.thread_manager);
let session_import_result = (!pending_session_imports.is_empty()).then(|| {
CoreImportItemResult::new(
@@ -234,13 +263,19 @@ impl ExternalAgentConfigRequestProcessor {
});
let pending_plugin_imports = import_outcome.pending_plugin_imports;
tokio::spawn(async move {
let session_progress_outgoing = Arc::clone(&outgoing);
let session_import_id = import_id.clone();
let session_imports = async move {
let session_import_result = session_import_result?;
let item_result = session_importer
.import_sessions(pending_session_imports, session_import_result)
.await;
send_import_progress(&session_progress_outgoing, &session_import_id, &item_result)
.await;
Some(item_result)
};
let plugin_progress_outgoing = Arc::clone(&outgoing);
let plugin_import_id = import_id.clone();
let plugin_imports = async move {
let mut item_results = Vec::new();
for pending_plugin_import in pending_plugin_imports {
@@ -265,6 +300,12 @@ impl ExternalAgentConfigRequestProcessor {
);
}
}
send_import_progress(
&plugin_progress_outgoing,
&plugin_import_id,
&item_result,
)
.await;
item_results.push(item_result);
}
item_results
@@ -280,12 +321,37 @@ impl ExternalAgentConfigRequestProcessor {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
}
send_completed_import_notification(&outgoing, import_id, &completed_item_results).await;
send_completed_import_notification(
&outgoing,
state_db.as_ref(),
import_id,
&completed_item_results,
)
.await;
});
Ok(())
}
pub(crate) async fn read_import_histories(
&self,
) -> Result<ExternalAgentConfigImportHistoriesReadResponse, JSONRPCErrorError> {
let state_db = self
.state_db
.as_ref()
.ok_or_else(|| internal_error("state database is unavailable"))?;
let histories = state_db
.external_agent_config_import_history_records()
.await
.map_err(|err| internal_error(format!("failed to read import histories: {err}")))?;
let data = histories
.into_iter()
.map(protocol_import_history)
.collect::<Result<Vec<_>, _>>()?;
Ok(ExternalAgentConfigImportHistoriesReadResponse { data })
}
fn validate_pending_session_imports(
&self,
params: &ExternalAgentConfigImportParams,
@@ -467,12 +533,37 @@ impl ExternalAgentConfigRequestProcessor {
}
}
async fn send_import_progress(
outgoing: &OutgoingMessageSender,
import_id: &str,
item_result: &CoreImportItemResult,
) {
outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportProgress(
ExternalAgentConfigImportProgressNotification {
import_id: import_id.to_string(),
item_type_results: vec![protocol_import_type_result(item_result)],
},
))
.await;
}
async fn send_completed_import_notification(
outgoing: &OutgoingMessageSender,
state_db: Option<&StateDbHandle>,
import_id: String,
item_results: &[CoreImportItemResult],
) {
let notification = completed_notification(import_id, item_results);
if let Some(state_db) = state_db
&& let Err(err) = record_completed_import_notification(state_db, &notification).await
{
tracing::warn!(
import_id = %notification.import_id,
error = %err,
"failed to record external agent config import completion"
);
}
outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
notification,
@@ -480,6 +571,103 @@ async fn send_completed_import_notification(
.await;
}
async fn record_completed_import_notification(
state_db: &StateDbHandle,
notification: &ExternalAgentConfigImportCompletedNotification,
) -> anyhow::Result<()> {
let successes = notification
.item_type_results
.iter()
.flat_map(|type_result| type_result.successes.iter())
.map(|success| {
Ok(ExternalAgentConfigImportSuccessRecord {
item_type: serde_json::from_value(serde_json::to_value(success.item_type)?)?,
cwd: success.cwd.clone(),
source: success.source.clone(),
target: success.target.clone(),
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
let failures = notification
.item_type_results
.iter()
.flat_map(|type_result| type_result.failures.iter())
.map(|failure| {
Ok(ExternalAgentConfigImportFailureRecord {
item_type: serde_json::from_value(serde_json::to_value(failure.item_type)?)?,
error_type: failure.error_type.clone(),
failure_stage: failure.failure_stage.clone(),
message: failure.message.clone(),
cwd: failure.cwd.clone(),
source: failure.source.clone(),
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
state_db
.record_external_agent_config_import_completed(
notification.import_id.as_str(),
&successes,
&failures,
)
.await
}
fn protocol_import_history(
record: codex_state::ExternalAgentConfigImportHistoryRecord,
) -> Result<ExternalAgentConfigImportHistory, JSONRPCErrorError> {
let successes = record
.successes
.into_iter()
.map(protocol_import_success_record)
.collect::<Result<Vec<_>, _>>()?;
let failures = record
.failures
.into_iter()
.map(protocol_import_failure_record)
.collect::<Result<Vec<_>, _>>()?;
Ok(ExternalAgentConfigImportHistory {
import_id: record.import_id,
completed_at_ms: record.completed_at_ms,
successes,
failures,
})
}
fn protocol_import_success_record(
record: ExternalAgentConfigImportSuccessRecord,
) -> Result<ProtocolImportSuccess, JSONRPCErrorError> {
Ok(ProtocolImportSuccess {
item_type: protocol_import_record_item_type(record.item_type)?,
cwd: record.cwd,
source: record.source,
target: record.target,
})
}
fn protocol_import_failure_record(
record: ExternalAgentConfigImportFailureRecord,
) -> Result<ProtocolImportFailure, JSONRPCErrorError> {
Ok(ProtocolImportFailure {
item_type: protocol_import_record_item_type(record.item_type)?,
error_type: record.error_type,
failure_stage: record.failure_stage,
message: record.message,
cwd: record.cwd,
source: record.source,
})
}
fn protocol_import_record_item_type(
item_type: String,
) -> Result<ExternalAgentConfigMigrationItemType, JSONRPCErrorError> {
serde_json::from_value(serde_json::Value::String(item_type.clone())).map_err(|err| {
internal_error(format!(
"failed to decode import item type {item_type}: {err}"
))
})
}
fn completed_notification(
import_id: String,
item_results: &[CoreImportItemResult],
@@ -529,6 +717,22 @@ fn completed_notification(
}
}
fn protocol_import_type_result(item_result: &CoreImportItemResult) -> ProtocolImportTypeResult {
ProtocolImportTypeResult {
item_type: protocol_migration_item_type(item_result.item_type),
successes: item_result
.successes
.iter()
.map(protocol_import_success)
.collect(),
failures: item_result
.raw_errors
.iter()
.map(protocol_import_raw_error)
.collect(),
}
}
fn protocol_import_success(
success: &crate::config::external_agent_config::ExternalAgentConfigImportSuccess,
) -> ProtocolImportSuccess {
@@ -543,6 +747,7 @@ fn protocol_import_success(
fn protocol_import_raw_error(raw_error: &CoreImportRawError) -> ProtocolImportFailure {
ProtocolImportFailure {
item_type: protocol_migration_item_type(raw_error.item_type),
error_type: raw_error.error_type.clone(),
failure_stage: raw_error.failure_stage.clone(),
message: raw_error.message.clone(),
cwd: raw_error.cwd.clone(),
@@ -7,6 +7,8 @@ use app_test_support::to_response;
use app_test_support::write_mock_responses_config_toml;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCError;
@@ -42,10 +44,17 @@ fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String
async fn external_agent_config_import_sends_completion_notification_for_sync_only_import()
-> Result<()> {
let codex_home = TempDir::new()?;
let sqlite_home = TempDir::new()?;
let home_dir = codex_home.path().display().to_string();
let mut mcp =
TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))])
.await?;
let sqlite_home_dir = sqlite_home.path().display().to_string();
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
("HOME", Some(home_dir.as_str())),
("CODEX_SQLITE_HOME", Some(sqlite_home_dir.as_str())),
],
)
.await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
@@ -68,6 +77,21 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
let import_id = assert_import_response(response);
let progress = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/progress"),
)
.await??;
assert_eq!(progress.method, "externalAgentConfig/import/progress");
let progress: ExternalAgentConfigImportProgressNotification =
serde_json::from_value(progress.params.expect("progress params"))?;
assert_eq!(progress.import_id, import_id);
assert_eq!(progress.item_type_results.len(), 1);
assert_eq!(
progress.item_type_results[0].item_type,
ExternalAgentConfigMigrationItemType::Config
);
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
@@ -77,6 +101,58 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl
let completed: ExternalAgentConfigImportCompletedNotification =
serde_json::from_value(notification.params.expect("completed params"))?;
assert_eq!(completed.import_id, import_id);
let state_db =
codex_state::StateRuntime::init(sqlite_home.path().to_path_buf(), "mock_provider".into())
.await?;
let details_record = state_db
.external_agent_config_import_details_record(&import_id)
.await?
.expect("completed import details should be recorded by import id");
let expected_successes = completed
.item_type_results
.iter()
.flat_map(|type_result| type_result.successes.iter())
.collect::<Vec<_>>();
let expected_failures = completed
.item_type_results
.iter()
.flat_map(|type_result| type_result.failures.iter())
.collect::<Vec<_>>();
assert_eq!(
serde_json::to_value(&details_record.successes)?,
serde_json::to_value(&expected_successes)?
);
assert_eq!(
serde_json::to_value(&details_record.failures)?,
serde_json::to_value(&expected_failures)?
);
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import/readHistories",
/*params*/ None,
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportHistoriesReadResponse = to_response(response)?;
let entry = response
.data
.iter()
.find(|entry| entry.import_id == import_id)
.expect("import history entry should be available");
assert!(entry.completed_at_ms > 0);
assert_eq!(
serde_json::to_value(&entry.successes)?,
serde_json::to_value(&expected_successes)?
);
assert_eq!(
serde_json::to_value(&entry.failures)?,
serde_json::to_value(&expected_failures)?
);
Ok(())
}
+8 -8
View File
@@ -155,13 +155,13 @@ pub fn missing_subagent_names(
Ok(names)
}
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<usize> {
pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Result<Vec<String>> {
if !source_agents.is_dir() {
return Ok(0);
return Ok(Vec::new());
}
fs::create_dir_all(target_agents)?;
let mut imported = 0usize;
let mut imported = Vec::new();
for source_file in agent_source_files(source_agents)? {
let Some(target) = subagent_target_file(&source_file, target_agents) else {
continue;
@@ -174,7 +174,7 @@ pub fn import_subagents(source_agents: &Path, target_agents: &Path) -> io::Resul
continue;
};
fs::write(&target, render_agent_toml(&document.body, &metadata)?)?;
imported += 1;
imported.push(metadata.name);
}
Ok(imported)
@@ -195,13 +195,13 @@ pub fn missing_command_names(
.collect())
}
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<usize> {
pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Result<Vec<String>> {
if !source_commands.is_dir() {
return Ok(0);
return Ok(Vec::new());
}
fs::create_dir_all(target_skills)?;
let mut imported = 0usize;
let mut imported = Vec::new();
for (source_file, name) in unique_supported_command_sources(source_commands)? {
let document = parse_document(&source_file)?;
let target_dir = target_skills.join(&name);
@@ -217,7 +217,7 @@ pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Resu
target_dir.join("SKILL.md"),
render_command_skill(&document.body, &name, &description, &source_name),
)?;
imported += 1;
imported.push(name);
}
Ok(imported)
@@ -0,0 +1,6 @@
CREATE TABLE external_agent_config_imports (
import_id TEXT PRIMARY KEY,
completed_at_ms INTEGER NOT NULL,
successes TEXT NOT NULL,
failures TEXT NOT NULL
);
+4
View File
@@ -56,6 +56,10 @@ pub use model::ThreadGoalStatus;
pub use model::ThreadMetadata;
pub use model::ThreadMetadataBuilder;
pub use model::ThreadsPage;
pub use runtime::ExternalAgentConfigImportDetailsRecord;
pub use runtime::ExternalAgentConfigImportFailureRecord;
pub use runtime::ExternalAgentConfigImportHistoryRecord;
pub use runtime::ExternalAgentConfigImportSuccessRecord;
pub use runtime::GoalAccountingMode;
pub use runtime::GoalAccountingOutcome;
pub use runtime::GoalStore;
+5
View File
@@ -59,6 +59,7 @@ use tracing::warn;
mod agent_jobs;
mod backfill;
mod external_agent_config_imports;
mod goals;
mod logs;
mod memories;
@@ -68,6 +69,10 @@ mod remote_control;
mod test_support;
mod threads;
pub use external_agent_config_imports::ExternalAgentConfigImportDetailsRecord;
pub use external_agent_config_imports::ExternalAgentConfigImportFailureRecord;
pub use external_agent_config_imports::ExternalAgentConfigImportHistoryRecord;
pub use external_agent_config_imports::ExternalAgentConfigImportSuccessRecord;
pub use goals::GoalAccountingMode;
pub use goals::GoalAccountingOutcome;
pub use goals::GoalStore;
@@ -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(())
}
@@ -161,6 +161,7 @@ pub(super) fn server_notification_thread_target(
| ServerNotification::AccountRateLimitsUpdated(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportProgress(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::DeprecationNotice(_)
| ServerNotification::ConfigWarning(_)
+1
View File
@@ -205,6 +205,7 @@ impl ChatWidget {
| ServerNotification::McpServerOauthLoginCompleted(_)
| ServerNotification::AppListUpdated(_)
| ServerNotification::RemoteControlStatusChanged(_)
| ServerNotification::ExternalAgentConfigImportProgress(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::FsChanged(_)
| ServerNotification::TurnModerationMetadata(_)