Skip to content
Open
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
74 changes: 72 additions & 2 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,51 @@ async fn launcher_main(args: Vec<String>, helper_only: bool, options: LaunchOpti
});
let hooks = LauncherHooks::default();
let handle = launch_and_inject_with_hooks(options, &hooks).await?;
handle.wait_for_codex_exit().await?;
run_periodic_until_exit(
handle.wait_for_codex_exit(),
std::time::Duration::from_secs(60),
|| repair_session_index_automatically(true),
)
.await?;
Ok(())
}

// 退出时不再安排下一次检查;已开始的数据库事务先完成,避免脱离启动器生命周期。
async fn run_periodic_until_exit<F, T, C, W>(exit: F, interval: std::time::Duration, mut check: C) -> T
where
F: std::future::Future<Output = T>,
C: FnMut() -> W,
W: std::future::Future<Output = ()>,
{
tokio::pin!(exit);
loop {
tokio::select! {
biased;
result = &mut exit => return result,
_ = tokio::time::sleep(interval) => check().await,
}
}
}

async fn repair_session_index_automatically(check_setting: bool) {
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if check_setting && !codex_plus_core::settings::SettingsStore::default().load()?.provider_sync_enabled {
return Ok(());
}
codex_plus_data::repair_session_index(None)?;
Ok(())
})
.await
.map_err(anyhow::Error::from)
.and_then(|result| result);
if let Err(error) = result {
let _ = codex_plus_core::diagnostic_log::append_diagnostic_log(
"launcher.session_index_repair.failed",
json!({ "message": error.to_string() }),
);
}
}

fn current_timestamp_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down Expand Up @@ -376,7 +417,9 @@ impl LaunchHooks for LauncherHooks {
let result = tokio::task::spawn_blocking(|| codex_plus_data::run_provider_sync(None))
.await
.map_err(|error| anyhow::anyhow!("provider sync task failed: {error}"))?;
require_completed_provider_sync(&result.status, &result.message)
require_completed_provider_sync(&result.status, &result.message)?;
repair_session_index_automatically(false).await;
Ok(())
}

fn has_pending_remote_control_session_recoveries(&self) -> bool {
Expand Down Expand Up @@ -1140,6 +1183,33 @@ fn default_user_scripts_config_dir() -> PathBuf {
mod tests {
use super::*;

#[tokio::test]
async fn session_index_monitor_does_not_start_after_exit() {
let checks = std::cell::Cell::new(0);
let result = run_periodic_until_exit(async { 42 }, std::time::Duration::ZERO, || {
checks.set(checks.get() + 1);
std::future::ready(())
}).await;
assert_eq!(result, 42);
assert_eq!(checks.get(), 0);
}

#[tokio::test]
async fn session_index_monitor_finishes_current_check_before_exit() {
let (done, exit) = tokio::sync::oneshot::channel::<()>();
let mut done = Some(done);
let finished = std::cell::Cell::new(false);
run_periodic_until_exit(exit, std::time::Duration::from_millis(1), || {
done.take().expect("only one check").send(()).unwrap();
let finished = &finished;
async move {
tokio::task::yield_now().await;
finished.set(true);
}
}).await.unwrap();
assert!(finished.get());
}

#[test]
fn parse_launch_options_accepts_manager_forwarded_ports_and_app_path() {
let options = parse_launch_options([
Expand Down
29 changes: 29 additions & 0 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3226,6 +3226,35 @@ fn merge_manual_provider_sync_targets(
});
}

#[tauri::command]
pub async fn repair_session_index() -> CommandResult<Value> {
let result = tauri::async_runtime::spawn_blocking(|| codex_plus_data::repair_session_index(None))
.await
.map_err(|error| anyhow::anyhow!("session index repair task failed: {error}"))
.and_then(|result| result);
match result {
Ok(report) => ok(
&format!("会话索引检查完成,恢复 {} 条消息。", report.repaired_items),
json!(report),
),
Err(error) => failed(&format!("修复会话索引失败:{error}"), json!({})),
}
}

#[tauri::command]
pub async fn load_session_index_repair_report() -> CommandResult<Value> {
let result = tauri::async_runtime::spawn_blocking(|| {
codex_plus_data::load_session_index_repair_report(None)
})
.await
.map_err(|error| anyhow::anyhow!("session index report task failed: {error}"))
.and_then(|result| result);
match result {
Ok(report) => ok("已读取会话索引修复报告。", json!({ "report": report })),
Err(error) => failed(&format!("读取会话索引修复报告失败:{error}"), json!({})),
}
}

#[tauri::command]
pub async fn preview_session_index_cleanup() -> CommandResult<Value> {
let result = tauri::async_runtime::spawn_blocking(|| {
Expand Down
2 changes: 2 additions & 0 deletions apps/codex-plus-manager/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ pub fn run() {
commands::forget_zed_remote_project,
commands::delete_local_session,
commands::load_provider_sync_targets,
commands::repair_session_index,
commands::load_session_index_repair_report,
commands::preview_session_index_cleanup,
commands::apply_session_index_cleanup,
commands::sync_providers_now,
Expand Down
141 changes: 139 additions & 2 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,28 @@ type TaskProgress = {
message: string;
};

type SessionIndexRepairReport = {
scannedFiles: number;
cachedFiles: number;
repairedItems: number;
alreadyPresent: number;
skippedItems: number;
deferredItems?: number;
issues: string[];
backupPath: string | null;
elapsedMs: number;
checkedAtMs?: number;
pendingDetails?: {
threadId: string | null;
turnId: string | null;
reason: string;
state: "waiting" | "blocked";
firstSeenAtMs: number;
lastCheckedAtMs: number;
checks: number;
}[];
};

type LogsResult = CommandResult<{
path: string;
text: string;
Expand Down Expand Up @@ -1186,6 +1208,10 @@ export function App() {
message: t("尚未检查官方远端插件缓存。"),
});
const [providerSyncTargets, setProviderSyncTargets] = useState<ProviderSyncTargetsResult | null>(null);
const [sessionIndexRepairActive, setSessionIndexRepairActive] = useState(false);
const sessionIndexRepairRunning = useRef(false);
const sessionIndexReportLoading = useRef(false);
const [sessionIndexRepairReport, setSessionIndexRepairReport] = useState<SessionIndexRepairReport | null>(null);
const [selectedProviderSyncTarget, setSelectedProviderSyncTarget] = useState("");
const [removeOwnedData, setRemoveOwnedData] = useState(false);
const [relaySwitching, setRelaySwitching] = useState(false);
Expand Down Expand Up @@ -2074,6 +2100,7 @@ export function App() {
await refreshSettings(true);
await refreshLocalSessions(true);
await refreshProviderSyncTargets(true);
await refreshSessionIndexRepairReport();
}
if (next === "zedRemote") {
await refreshSettings(true);
Expand Down Expand Up @@ -2537,7 +2564,48 @@ export function App() {
return result;
};

const refreshSessionIndexRepairReport = async (isCurrent = () => true) => {
if (sessionIndexReportLoading.current || sessionIndexRepairRunning.current) return;
sessionIndexReportLoading.current = true;
try {
// 持久报告读取失败时保留现有结果,后台刷新不触发全局通知或忙碌状态。
const result = await call<CommandResult<{ report: SessionIndexRepairReport | null }>>(
"load_session_index_repair_report",
);
if (isCurrent() && !sessionIndexRepairRunning.current && isSuccessStatus(result.status)) {
setSessionIndexRepairReport((previous) => {
if ((previous?.checkedAtMs ?? 0) > (result.report?.checkedAtMs ?? 0)) return previous;
return result.report;
});
}
} catch {
// 下次页面刷新再读;读取报告本身不会启动修复。
} finally {
sessionIndexReportLoading.current = false;
}
};

const repairSessionIndex = async () => {
if (sessionIndexRepairRunning.current || providerSyncProgress.active) return;
sessionIndexRepairRunning.current = true;
setSessionIndexRepairActive(true);
try {
const result = await run(() => call<CommandResult<SessionIndexRepairReport>>("repair_session_index"));
if (result) {
if (isSuccessStatus(result.status)) {
setSessionIndexRepairReport(result);
await refreshLocalSessions(true);
}
showNotice(t("修复会话索引"), result.message, result.status);
}
} finally {
sessionIndexRepairRunning.current = false;
setSessionIndexRepairActive(false);
}
};

const syncProvidersNow = async () => {
if (sessionIndexRepairRunning.current) return;
if (providerSyncProgress.active) return;
setProviderSyncProgress({
active: true,
Expand Down Expand Up @@ -3048,6 +3116,18 @@ export function App() {
};
}, []);

useEffect(() => {
if (route !== "sessions") return;
let disposed = false;
const refresh = () => void refreshSessionIndexRepairReport(() => !disposed);
refresh();
const timer = window.setInterval(refresh, 15_000);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [route]);

useEffect(() => {
if (route !== "settings" || pendingSettingsSection !== "stepwise") return;
let secondFrame = 0;
Expand Down Expand Up @@ -3337,6 +3417,7 @@ export function App() {
}
},
syncProvidersNow,
repairSessionIndex,
refreshProviderSyncTargets,
setProviderSyncTarget: (provider: string) => {
setSelectedProviderSyncTarget(provider);
Expand Down Expand Up @@ -3545,6 +3626,8 @@ export function App() {
form={settingsForm}
sessions={localSessions}
providerSyncProgress={providerSyncProgress}
sessionIndexRepairActive={sessionIndexRepairActive}
sessionIndexRepairReport={sessionIndexRepairReport}
providerSyncTargets={providerSyncTargets}
selectedProviderSyncTarget={selectedProviderSyncTarget}
onFormChange={setSettingsForm}
Expand Down Expand Up @@ -3762,6 +3845,7 @@ type Actions = {
saveDreamSkinScreenshot: () => Promise<void>;
saveManualCodexAppPath: () => Promise<void>;
syncProvidersNow: () => Promise<void>;
repairSessionIndex: () => Promise<void>;
refreshProviderSyncTargets: (silent?: boolean) => Promise<ProviderSyncTargetsResult | null>;
setProviderSyncTarget: (provider: string) => void;
setLaunchMode: (launchMode: LaunchMode) => Promise<void>;
Expand Down Expand Up @@ -6140,6 +6224,8 @@ function SessionsScreen({
form,
sessions,
providerSyncProgress,
sessionIndexRepairActive,
sessionIndexRepairReport,
providerSyncTargets,
selectedProviderSyncTarget,
onFormChange,
Expand All @@ -6149,6 +6235,8 @@ function SessionsScreen({
form: BackendSettings;
sessions: LocalSessionsResult | null;
providerSyncProgress: ProviderSyncProgress;
sessionIndexRepairActive: boolean;
sessionIndexRepairReport: SessionIndexRepairReport | null;
providerSyncTargets: ProviderSyncTargetsResult | null;
selectedProviderSyncTarget: string;
onFormChange: (value: BackendSettings) => void;
Expand Down Expand Up @@ -6277,7 +6365,7 @@ function SessionsScreen({
/>
<span>
<strong>{t("启动前自动修复历史会话")}</strong>
<small>{t("启动 Codex 前整理旧对话的归属标记。")}</small>
<small>{t("启动前整理会话归属并检查缺失消息;运行期间自动检查索引。保存设置后生效。")}</small>
</span>
<ToggleVisual />
</label>
Expand All @@ -6292,13 +6380,21 @@ function SessionsScreen({
{t("导入文件")}
</Button>
<Button
disabled={providerSyncProgress.active || !canRepairProviderSessions}
disabled={providerSyncProgress.active || sessionIndexRepairActive || !canRepairProviderSessions}
onClick={() => void actions.syncProvidersNow()}
variant="outline"
>
<Wrench className="h-4 w-4" />
{providerSyncProgress.active ? t("正在修复…") : t("修复历史会话")}
</Button>
<Button
disabled={sessionIndexRepairActive || providerSyncProgress.active}
onClick={() => void actions.repairSessionIndex()}
variant="outline"
>
<Wrench className="h-4 w-4" />
{sessionIndexRepairActive ? t("正在检查索引…") : t("修复会话索引")}
</Button>
<Button onClick={() => void actions.saveSettings()}>
<Save className="h-4 w-4" />
{t("保存设置")}
Expand Down Expand Up @@ -6337,6 +6433,47 @@ function SessionsScreen({
</div>
) : null}

{sessionIndexRepairActive ? (
<p role="status">{t("正在检查全部会话并恢复高可信缺失消息,首次检查可能需要较长时间…")}</p>
) : null}
{sessionIndexRepairReport ? (
<div className="provider-sync-progress session-repair-progress" aria-live="polite">
<strong>{t("最近一次会话索引修复报告")}</strong>
<p>{t("最后检查:")}{sessionIndexRepairReport.checkedAtMs ? formatTime(sessionIndexRepairReport.checkedAtMs) : t("旧版报告未记录时间")}</p>
<p>
{t("读取文件")} {sessionIndexRepairReport.scannedFiles} · {t("复用缓存")} {sessionIndexRepairReport.cachedFiles} · {t("耗时")} {(sessionIndexRepairReport.elapsedMs / 1000).toFixed(1)} s
</p>
<p>
{t("恢复消息")} {sessionIndexRepairReport.repairedItems} · {t("已存在")} {sessionIndexRepairReport.alreadyPresent} · {t("短暂等待")} {sessionIndexRepairReport.deferredItems ?? 0} · {t("需核查")} {sessionIndexRepairReport.skippedItems}
</p>
<small>{t("仅恢复有本地原文且可确认位置的消息;已打开的会话可能需要重新打开才能显示。")}</small>
<p><small>{t("自动检查需要 Codex++ 启动器运行,且自动修复开关已开启并保存。此页面每 15 秒刷新报告,不会单独启动修复;再次检查不保证恢复。")}</small></p>
<p><small>{t("短暂等待最长 30 分钟;原文和记录文件都已超过 24 小时未更新的项目直接转入需核查。缺少对应轮次或结束状态,当前证据不足以安全补回;后续检查仍会核验。")}</small></p>
{sessionIndexRepairReport.backupPath ? <p className="break-all">{t("修复前备份:")}{sessionIndexRepairReport.backupPath}</p> : null}
{sessionIndexRepairReport.pendingDetails?.length ? (
<details>
<summary>{t("等待与持续无法恢复详情")} ({sessionIndexRepairReport.pendingDetails.length})</summary>
<ul>
{sessionIndexRepairReport.pendingDetails.map((item, index) => (
<li key={`${item.threadId}-${item.turnId}-${index}`} className="break-all my-3">
<strong>{item.state === "waiting" ? t("短暂等待") : t("持续无法恢复")}</strong>
<p>{t("任务 ID:")}{item.threadId ?? "—"} · {t("轮次 ID:")}{item.turnId ?? "—"}</p>
<p>{t("原因:")}{item.reason}</p>
<small>{t("首次发现:")}{formatTime(item.firstSeenAtMs)} · {t("最后检查:")}{formatTime(item.lastCheckedAtMs)} · {t("检查次数:")}{item.checks}</small>
</li>
))}
</ul>
</details>
) : null}
{sessionIndexRepairReport.issues.length ? (
<details>
<summary>{t("查看检查详情")} ({sessionIndexRepairReport.issues.length})</summary>
<ul>{sessionIndexRepairReport.issues.map((issue, index) => <li key={index} className="break-all">{issue}</li>)}</ul>
</details>
) : null}
</div>
) : null}

<div className="hint-line session-delete-hint">
<Info className="h-4 w-4" />
<span>{t("删除会创建本地备份;如果 Codex App 正在使用该会话,建议先关闭对应会话窗口再操作。")}</span>
Expand Down
Loading
Loading