Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 32 additions & 2 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,10 +355,10 @@ impl LaunchHooks for LauncherHooks {
}

async fn run_provider_sync(&self) -> anyhow::Result<()> {
let _ = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None))
let result = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None))
.await
.map_err(|error| anyhow::anyhow!("provider sync task failed: {error}"))?;
Ok(())
require_completed_provider_sync(&result.status, &result.message)
}

fn has_pending_remote_control_session_recoveries(&self) -> bool {
Expand Down Expand Up @@ -607,6 +607,16 @@ impl LaunchHooks for LauncherHooks {
}
}

fn require_completed_provider_sync(
status: &codex_plus_data::ProviderSyncStatus,
message: &str,
) -> anyhow::Result<()> {
if *status == codex_plus_data::ProviderSyncStatus::Synced {
return Ok(());
}
anyhow::bail!("provider sync did not complete ({status:?}): {message}")
}

#[derive(Debug, Clone)]
struct LauncherDataService {
db_path: PathBuf,
Expand Down Expand Up @@ -1136,6 +1146,26 @@ mod tests {
assert_eq!(options.helper_port, LaunchOptions::default().helper_port);
}

#[test]
fn launcher_accepts_only_a_completed_provider_sync() {
assert!(
require_completed_provider_sync(
&codex_plus_data::ProviderSyncStatus::Synced,
"Provider sync complete",
)
.is_ok()
);

for status in [
codex_plus_data::ProviderSyncStatus::Disabled,
codex_plus_data::ProviderSyncStatus::Skipped,
] {
let error = require_completed_provider_sync(&status, "target is unresolved")
.expect_err("an incomplete provider sync must stop launch");
assert!(error.to_string().contains("target is unresolved"));
}
}

#[test]
fn launcher_uses_single_instance_guard_before_launching() {
let source = include_str!("main.rs");
Expand Down
85 changes: 84 additions & 1 deletion apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2944,7 +2944,7 @@ pub async fn load_provider_sync_targets() -> CommandResult<Value> {
}
})
.collect::<Vec<_>>();
merge_manual_provider_sync_targets(&mut targets, &manual, &settings);
merge_manual_provider_sync_targets(&mut targets, &manual, &settings, None);
ok(
"Provider 同步目标已加载。",
serde_json::to_value(targets).unwrap_or_else(|_| json!({})),
Expand All @@ -2958,6 +2958,7 @@ fn merge_manual_provider_sync_targets(
targets: &mut codex_plus_data::ProviderSyncTargetList,
manual: &[String],
settings: &BackendSettings,
codex_home: Option<&Path>,
) {
for id in manual {
if let Some(existing) = targets.targets.iter_mut().find(|target| target.id == *id) {
Expand All @@ -2973,6 +2974,11 @@ fn merge_manual_provider_sync_targets(
existing.is_manual = settings.provider_sync_manual_providers.contains(id);
existing.is_saved = settings.provider_sync_saved_providers.contains(id);
} else {
let (is_resolvable, unavailable_reason) =
match codex_plus_data::validate_provider_sync_target(codex_home, Some(id)) {
Ok(_) => (true, None),
Err(reason) => (false, Some(reason)),
};
targets
.targets
.push(codex_plus_data::ProviderSyncTargetOption {
Expand All @@ -2981,6 +2987,8 @@ fn merge_manual_provider_sync_targets(
is_current_provider: *id == targets.current_provider,
is_manual: settings.provider_sync_manual_providers.contains(id),
is_saved: settings.provider_sync_saved_providers.contains(id),
is_resolvable,
unavailable_reason,
});
}
}
Expand Down Expand Up @@ -3056,6 +3064,21 @@ pub async fn sync_providers_now(target_provider: Option<String>) -> CommandResul
let target_provider = target_provider
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
let target_for_validation = target_provider.clone();
match tauri::async_runtime::spawn_blocking(move || {
codex_plus_data::validate_provider_sync_target(None, target_for_validation.as_deref())
})
.await
{
// Keep None as "use current" so the sync reads the live selection again.
Ok(Ok(_)) => {}
Ok(Err(message)) => return provider_sync_preflight_failure(&message),
Err(error) => {
return provider_sync_preflight_failure(&format!(
"provider target validation task failed: {error}"
));
}
};
let target_for_settings = target_provider.clone();
let home = codex_plus_core::relay_config::default_codex_home_dir();
prepare_codex_app_state_before_provider_switch(&home, "manager.sync_providers_now.before");
Expand Down Expand Up @@ -3100,6 +3123,17 @@ pub async fn sync_providers_now(target_provider: Option<String>) -> CommandResul
}
}

fn provider_sync_preflight_failure(message: &str) -> CommandResult<Value> {
failed(
&format!("供应商同步未执行:{message}"),
json!({
"syncStatus": "skipped",
"targetProvider": "",
"syncMessage": message,
}),
)
}

fn is_success_sync_status(status: &codex_plus_data::ProviderSyncStatus) -> bool {
matches!(status, codex_plus_data::ProviderSyncStatus::Synced)
}
Expand Down Expand Up @@ -6227,6 +6261,55 @@ mod tests {
assert_eq!(result.payload["syncStatus"], "skipped");
}

#[test]
fn provider_sync_preflight_failure_is_structured_as_skipped() {
let result = provider_sync_preflight_failure("target is not resolvable");

assert_eq!(result.status, "failed");
assert_eq!(result.payload["syncStatus"], "skipped");
assert_eq!(result.payload["targetProvider"], "");
assert_eq!(result.payload["syncMessage"], "target is not resolvable");
}

#[test]
fn manual_provider_sync_targets_keep_unresolvable_history_visible() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("config.toml"),
r#"model_provider = "relay-live"

[model_providers.relay-live]
base_url = "https://example.invalid/v1"
"#,
)
.unwrap();
let mut settings = BackendSettings::default();
settings.provider_sync_manual_providers =
vec!["relay-live".to_string(), "relay-history".to_string()];
let manual = settings.provider_sync_manual_providers.clone();
let mut targets = codex_plus_data::ProviderSyncTargetList {
current_provider: "relay-live".to_string(),
targets: Vec::new(),
};

merge_manual_provider_sync_targets(&mut targets, &manual, &settings, Some(temp.path()));

let live = targets
.targets
.iter()
.find(|target| target.id == "relay-live")
.unwrap();
assert!(live.is_resolvable);
assert!(live.unavailable_reason.is_none());
let history = targets
.targets
.iter()
.find(|target| target.id == "relay-history")
.unwrap();
assert!(!history.is_resolvable);
assert!(history.unavailable_reason.is_some());
}

#[test]
fn provider_sync_synced_is_reported_as_command_success() {
let result = provider_sync_command_result(provider_sync_result_for_test(
Expand Down
47 changes: 32 additions & 15 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ import {
} from "./model-windows";
import { relayAuthForLiveDraft, shouldBackfillRelayProfileBeforeSwitch } from "./relay-live-files";
import { resolveProviderSyncCompletion } from "./provider-sync-flow";
import { isProviderSyncTargetSelectable, preferredProviderSyncTarget } from "./provider-sync-target";
import { resolveLaunchStatus } from "./launch-status";
import {
defaultDreamSkinTheme,
Expand Down Expand Up @@ -708,6 +709,8 @@ type ProviderSyncTargetOption = {
isCurrentProvider: boolean;
isManual: boolean;
isSaved: boolean;
isResolvable: boolean;
unavailableReason: string | null;
};

type ProviderSyncTargetsPayload = {
Expand Down Expand Up @@ -840,7 +843,8 @@ const providerSyncSourceLabels: Record<ProviderSyncTargetSource, string> = {
function providerSyncTargetLabel(target: ProviderSyncTargetOption): string {
const labels = target.sources.map((source) => providerSyncSourceLabels[source]).filter(Boolean);
const current = target.isCurrentProvider ? [t("当前")] : [];
return [...labels, ...current].join(" / ") || t("发现");
const unavailable = isProviderSyncTargetSelectable(target) ? [] : [t("供应商切换不可用")];
return [...labels, ...current, ...unavailable].join(" / ") || t("发现");
}

function syncMarketInstalledState(current: ScriptMarketResult | null, userScripts: UserScriptInventory): ScriptMarketResult | null {
Expand Down Expand Up @@ -2403,12 +2407,8 @@ export function App() {
setProviderSyncTargets(result);
const targets = result.targets ?? [];
const saved = settingsForm.providerSyncLastSelectedProvider;
const preferred =
targets.find((target) => target.id === saved)?.id ||
targets.find((target) => target.isCurrentProvider)?.id ||
targets[0]?.id ||
"openai";
setSelectedProviderSyncTarget((current) => (targets.some((target) => target.id === current) ? current : preferred));
const preferred = preferredProviderSyncTarget(targets, result.currentProvider, saved);
setSelectedProviderSyncTarget(preferred);
if (!silent && !isSuccessStatus(result.status)) showNotice(t("Provider 同步目标"), result.message, result.status);
}
return result;
Expand Down Expand Up @@ -3184,7 +3184,6 @@ export function App() {
refreshProviderSyncTargets,
setProviderSyncTarget: (provider: string) => {
setSelectedProviderSyncTarget(provider);
setSettingsForm((current) => ({ ...current, providerSyncLastSelectedProvider: provider }));
},
setLaunchMode: async (launchMode: LaunchMode) => {
await saveLaunchMode(launchMode);
Expand Down Expand Up @@ -5909,6 +5908,13 @@ function SessionsScreen({
const selectedSessions = useMemo(() => items.filter((session) => selectedSessionIds.has(session.id)), [items, selectedSessionIds]);
const selectedCount = selectedSessions.length;
const allSelected = items.length > 0 && selectedCount === items.length;
const providerTargets = providerSyncTargets?.targets ?? [];
const selectedProviderTarget = providerTargets.find(
(target) => target.id === selectedProviderSyncTarget,
);
const canRepairProviderSessions = selectedProviderTarget
? isProviderSyncTargetSelectable(selectedProviderTarget)
: false;

useEffect(() => {
const itemIds = new Set(items.map((session) => session.id));
Expand Down Expand Up @@ -5981,15 +5987,22 @@ function SessionsScreen({
<div className="session-repair-tools">
<Field className="session-sync-target" label={t("同步目标")}>
<AppSelect
disabled={providerSyncProgress.active || !(providerSyncTargets?.targets ?? []).length}
disabled={providerSyncProgress.active || !providerTargets.length}
value={selectedProviderSyncTarget}
onChange={(value) => actions.setProviderSyncTarget(value)}
options={
(providerSyncTargets?.targets ?? []).length
? (providerSyncTargets?.targets ?? []).map((target) => ({
value: target.id,
label: `${target.id}${t("(")}${providerSyncTargetLabel(target)}${t(")")}`,
}))
providerTargets.length
? [
...(!selectedProviderSyncTarget
? [{ value: "", label: t("当前配置 provider"), disabled: true }]
: []),
...providerTargets.map((target) => ({
value: target.id,
label: `${target.id}${t("(")}${providerSyncTargetLabel(target)}${t(")")}`,
disabled: !isProviderSyncTargetSelectable(target),
title: target.unavailableReason ?? undefined,
})),
]
: [{ value: "", label: t("当前配置 provider"), disabled: true }]
}
/>
Expand Down Expand Up @@ -6017,7 +6030,11 @@ function SessionsScreen({
<PackageOpen className="h-4 w-4" />
{t("导入文件")}
</Button>
<Button disabled={providerSyncProgress.active} onClick={() => void actions.syncProvidersNow()} variant="outline">
<Button
disabled={providerSyncProgress.active || !canRepairProviderSessions}
onClick={() => void actions.syncProvidersNow()}
variant="outline"
>
<Wrench className="h-4 w-4" />
{providerSyncProgress.active ? t("正在修复…") : t("修复历史会话")}
</Button>
Expand Down
39 changes: 39 additions & 0 deletions apps/codex-plus-manager/src/provider-sync-target.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
isProviderSyncTargetSelectable,
preferredProviderSyncTarget,
} from "./provider-sync-target.ts";

test("the exact current provider takes priority over a saved provider", () => {
const targets = [
{ id: "relay-alpha", isCurrentProvider: true, isResolvable: true },
{ id: "relay-beta", isCurrentProvider: false, isResolvable: true },
];

assert.equal(preferredProviderSyncTarget(targets, "relay-alpha", "relay-beta"), "relay-alpha");
});

test("a resolvable saved provider is used when the current provider is unavailable", () => {
const targets = [
{ id: "relay-alpha", isCurrentProvider: true, isResolvable: false },
{ id: "relay-beta", isCurrentProvider: false, isResolvable: true },
];

assert.equal(preferredProviderSyncTarget(targets, "relay-alpha", "relay-beta"), "relay-beta");
});

test("history-only and legacy targets fail closed", () => {
const historyOnly = { id: "relay-history", isCurrentProvider: false, isResolvable: false };
const legacy = { id: "relay-legacy", isCurrentProvider: false };

assert.equal(isProviderSyncTargetSelectable(historyOnly), false);
assert.equal(
isProviderSyncTargetSelectable(
legacy as unknown as Parameters<typeof isProviderSyncTargetSelectable>[0],
),
false,
);
assert.equal(preferredProviderSyncTarget([historyOnly], "relay-history", "relay-history"), "");
});
23 changes: 23 additions & 0 deletions apps/codex-plus-manager/src/provider-sync-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type ProviderSyncSelectableTarget = {
id: string;
isCurrentProvider: boolean;
isResolvable: boolean;
};

export function isProviderSyncTargetSelectable(target: ProviderSyncSelectableTarget): boolean {
return target.isResolvable === true;
}

export function preferredProviderSyncTarget(
targets: readonly ProviderSyncSelectableTarget[],
currentProvider: string,
savedProvider: string,
): string {
return (
targets.find((target) => target.id === currentProvider && isProviderSyncTargetSelectable(target))?.id ??
targets.find((target) => target.isCurrentProvider && isProviderSyncTargetSelectable(target))?.id ??
targets.find((target) => target.id === savedProvider && isProviderSyncTargetSelectable(target))?.id ??
targets.find(isProviderSyncTargetSelectable)?.id ??
""
);
}
1 change: 1 addition & 0 deletions crates/codex-plus-data/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order"] }
sha2.workspace = true
thiserror.workspace = true
toml.workspace = true
uuid.workspace = true

[dev-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion crates/codex-plus-data/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ pub use provider_sync::{
run_provider_sync_with_target,
run_remote_control_session_catalog_recovery_for_thread_with_target,
run_remote_control_session_finalization_for_thread_with_target,
try_acquire_provider_sync_lifecycle_guard,
try_acquire_provider_sync_lifecycle_guard, validate_provider_sync_target,
};
pub use storage::{LocalSession, SQLiteStorageAdapter, delete_local_from_paths};
Loading