feat(agents): concurrent all-agent-driver upgrade

This commit is contained in:
Abeautifulsnow 2026-07-24 23:50:50 +08:00 committed by GitHub
parent 2a99f91099
commit 95db01e359
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1083 additions and 282 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch, nextTick, type Ref } from "vue";
import { ref, reactive, onMounted, onUnmounted, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import { Activity, ExternalLink, Cpu, FolderOpen, FolderSync, MemoryStick, Search, Square, Trash2, Download, RotateCcw, Loader2, RefreshCw, Check, Clock3, FileUp } from "@lucide/vue";
import { Button } from "@/components/ui/button";
@ -18,7 +18,17 @@ import type { JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, JdbcPlug
import * as api from "@/lib/backend/api";
import type { AgentDriverInfo, DriverRuntimeInfo, DriverRuntimeSummary, DriverStoreUsage, JavaRuntimeConfig } from "@/lib/backend/api";
import { formatRuntimeBytes, formatRuntimeCpu, formatRuntimeUptime, runtimeHealthClass, runtimeStatusClass, runtimeStatusDotClass } from "@/lib/connection/driverRuntimePresentation";
import { addDriverInstallQueue, driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressTarget, removeDriverInstallQueue, takeNextDriverInstallQueue, updateDriverInstallProgress, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import {
addDriverInstallQueue,
driverInstallProgressChannel,
driverInstallProgressPercent,
isDriverInstallProgressForOperation,
isDriverInstallProgressTarget,
removeDriverInstallQueue,
takeNextDriverInstallQueue,
updatePerDriverProgress,
type DriverInstallProgress,
} from "@/lib/connection/driverInstallProgressUi";
import { PRESTOSQL_DRIVER_DB_TYPE, prestoSqlBuiltinDriverRow, prestoSqlMavenBundle } from "@/lib/database/prestoSqlBuiltinDriver";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
@ -166,13 +176,13 @@ const drivers = ref<AgentDriverInfo[]>([]);
const agentDriverSearch = ref("");
const installing = ref<string | null>(null);
const upgradingAll = ref(false);
const upgradingCurrent = ref("");
const upgradingIndex = ref(0);
const upgradingCompletedCount = ref(0);
const upgradingTotal = ref(0);
const queuedDriverInstalls = ref<string[]>([]);
const reinstallingJre = ref<string | null>(null);
const activeAgentOperationId = ref<string | null>(null);
const refreshing = ref(false);
const agentProgress = ref<DriverInstallProgress | null>(null);
const agentProgressByDbType = reactive<Record<string, DriverInstallProgress | null | undefined>>({});
const jdbcPluginProgress = ref<DriverInstallProgress | null>(null);
const javaRuntimeConfig = ref<JavaRuntimeConfig>({ mode: "managed", custom_java_path: null });
const customJavaPath = ref("");
@ -188,7 +198,7 @@ const DRIVER_RUNTIME_POLL_MS = 5000;
const OFFLINE_DRIVER_DOWNLOAD_URL = "https://dbxio.com/cn/drivers";
let unlisten: (() => void) | null = null;
const lastAgentProgressPercent = ref<number | null>(null);
const lastAgentProgressPercent: Record<string, number> = {};
const lastJdbcPluginProgressPercent = ref<number | null>(null);
const installedJres = computed(() => {
@ -201,7 +211,7 @@ const installedJres = computed(() => {
return [...jreMap.entries()].map(([key, installed]) => ({ key, installed })).sort((a, b) => b.key.localeCompare(a.key));
});
function formatProgressText(p: DriverInstallProgress | null, includeBatch: boolean): string {
function formatProgressText(p: DriverInstallProgress | null | undefined): string {
if (!p) return "";
if (p.step === "jre-extract") return t("driverStore.progressJreExtract");
if (p.step === "jdbc-plugin-extract") return t("driverStore.progressJdbcPluginExtract");
@ -210,27 +220,49 @@ function formatProgressText(p: DriverInstallProgress | null, includeBatch: boole
const pct = Math.round(((p.downloaded ?? 0) / p.total) * 100);
const dl = formatSize(p.downloaded ?? 0);
const total = formatSize(p.total);
const prefix = includeBatch && upgradingAll.value && upgradingCurrent.value ? `[${upgradingIndex.value}/${upgradingTotal.value}] ${upgradingCurrent.value} - ` : "";
return `${prefix}${label} ${dl} / ${total} (${pct}%)`;
return `${label} ${dl} / ${total} (${pct}%)`;
}
const agentProgressText = computed(() => formatProgressText(agentProgress.value, true));
const jdbcPluginProgressText = computed(() => formatProgressText(jdbcPluginProgress.value, false));
function getAgentProgressText(dbType: string): string {
return formatProgressText(agentProgressByDbType[dbType]);
}
function progressNumber(progress: DriverInstallProgress | null, lastProgressPercent: Ref<number | null>): number | null {
function getJdbcPluginProgressTitle(fallback: string): string {
return jdbcPluginProgressText.value || fallback;
}
function getAgentProgressTitle(dbType: string, fallback: string): string {
return getAgentProgressText(dbType) || formatProgressText(agentProgressByDbType[dbType]) || fallback;
}
const jdbcPluginProgressText = computed(() => formatProgressText(jdbcPluginProgress.value));
function getAgentProgressPercent(dbType: string): number | null {
const progress = agentProgressByDbType[dbType];
const next = driverInstallProgressPercent(progress);
if (next !== null) {
lastProgressPercent.value = next;
lastAgentProgressPercent[dbType] = next;
}
return next ?? lastProgressPercent.value;
return next ?? lastAgentProgressPercent[dbType] ?? null;
}
const agentProgressNumber = computed(() => progressNumber(agentProgress.value, lastAgentProgressPercent));
const jdbcPluginProgressNumber = computed(() => progressNumber(jdbcPluginProgress.value, lastJdbcPluginProgressPercent));
const jdbcPluginProgressNumber = computed(() => {
const next = driverInstallProgressPercent(jdbcPluginProgress.value);
if (next !== null) {
lastJdbcPluginProgressPercent.value = next;
}
return next ?? lastJdbcPluginProgressPercent.value;
});
function resetAgentInstallProgress() {
agentProgress.value = null;
lastAgentProgressPercent.value = null;
for (const key of Object.keys(agentProgressByDbType)) {
delete agentProgressByDbType[key];
}
for (const key of Object.keys(lastAgentProgressPercent)) {
delete lastAgentProgressPercent[key];
}
jreReinstallProgress.value = null;
lastJreReinstallPercent.value = null;
}
function resetJdbcPluginInstallProgress() {
@ -281,7 +313,7 @@ function isDriverProgressActive(dbType: string): boolean {
return isDriverInstallProgressTarget(dbType, {
installing: installing.value,
upgradingAll: upgradingAll.value,
progress: agentProgress.value,
progressMap: agentProgressByDbType,
});
}
@ -289,12 +321,18 @@ function driverRequiresJavaRuntime(driver: AgentDriverInfo): boolean {
return driver.requires_java_runtime ?? Boolean(driver.jre);
}
function agentProgressTitle(fallback: string): string {
return agentProgressText.value || fallback;
// JRE reinstall is single-threaded, so a simpler model suffices.
const jreReinstallProgress = ref<DriverInstallProgress | null>(null);
const lastJreReinstallPercent = ref<number | null>(null);
function getJreReinstallPercent(): number | null {
const next = driverInstallProgressPercent(jreReinstallProgress.value);
if (next !== null) lastJreReinstallPercent.value = next;
return next ?? lastJreReinstallPercent.value;
}
function jdbcPluginProgressTitle(fallback: string): string {
return jdbcPluginProgressText.value || fallback;
function getJreReinstallTitle(fallback: string): string {
return formatProgressText(jreReinstallProgress.value) || fallback;
}
function isPrestoSqlBuiltinDriver(dbType: string): boolean {
@ -408,6 +446,7 @@ async function installDriver(dbType: string) {
async function runDriverInstall(dbType: string) {
const label = driverLabel(dbType);
installing.value = dbType;
activeAgentOperationId.value = crypto.randomUUID();
resetAgentInstallProgress();
try {
if (isPrestoSqlBuiltinDriver(dbType)) {
@ -426,13 +465,14 @@ async function runDriverInstall(dbType: string) {
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
return;
}
await api.installAgent(dbType);
await api.installAgent(dbType, activeAgentOperationId.value);
await refreshAgents();
toast(t("driverStore.driverInstallSuccess", { label }));
} catch (e: any) {
toast(t("driverStore.driverInstallFailed", { label, error: e }));
} finally {
installing.value = null;
activeAgentOperationId.value = null;
resetAgentInstallProgress();
}
}
@ -450,16 +490,19 @@ async function runQueuedDriverInstalls() {
async function upgradeAll() {
upgradingAll.value = true;
activeAgentOperationId.value = crypto.randomUUID();
upgradingCompletedCount.value = 0;
queuedDriverInstalls.value = [];
resetAgentInstallProgress();
try {
const updatableDbTypes = drivers.value.filter((driver) => driver.update_available).map((driver) => driver.db_type);
upgradingTotal.value = updatableDbTypes.length;
const blockers = await api.checkAgentUpdateBlockers(updatableDbTypes);
if (blockers.length > 0) {
toast(t("driverStore.driverUpdateBlocked", { labels: blockers.map((blocker) => blocker.label).join(", ") }));
return;
}
const result = await api.upgradeAllAgents();
const result = await api.upgradeAllAgents(activeAgentOperationId.value);
await refreshAgents();
if (result.failed.length > 0) {
const failedLabels = result.failed.map((item) => drivers.value.find((driver) => driver.db_type === item.db_type)?.label ?? item.db_type).join(", ");
@ -471,8 +514,8 @@ async function upgradeAll() {
toast(t("driverStore.upgradeAllFailed", { error: e }));
} finally {
upgradingAll.value = false;
upgradingCurrent.value = "";
upgradingIndex.value = 0;
activeAgentOperationId.value = null;
upgradingCompletedCount.value = 0;
upgradingTotal.value = 0;
resetAgentInstallProgress();
}
@ -554,15 +597,17 @@ async function importOfflineZip() {
}
if (!selected) return;
importingZip.value = true;
activeAgentOperationId.value = crypto.randomUUID();
resetAgentInstallProgress();
try {
const count = await api.importAgentsFromZip(selected);
const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
await refreshAgents();
toast(t("driverStore.offlineImportSuccess", { count }));
} catch (e: any) {
toast(t("driverStore.offlineImportFailed", { error: e }));
} finally {
importingZip.value = false;
activeAgentOperationId.value = null;
resetAgentInstallProgress();
}
}
@ -583,9 +628,16 @@ async function importDriverFile(driver: AgentDriverInfo) {
const isWindows = navigator.userAgent.toLowerCase().includes("windows");
const installSelectedFile = async (selected: string | File) => {
if (isOfflineDriverPackage(selected)) {
const count = await api.importAgentsFromZip(selected);
await refreshAgents();
toast(t("driverStore.offlineImportSuccess", { count }));
activeAgentOperationId.value = crypto.randomUUID();
resetAgentInstallProgress();
try {
const count = await api.importAgentsFromZip(selected, activeAgentOperationId.value);
await refreshAgents();
toast(t("driverStore.offlineImportSuccess", { count }));
} finally {
activeAgentOperationId.value = null;
resetAgentInstallProgress();
}
} else {
await api.importAgentDriver(dbType, selected);
await refreshAgents();
@ -620,15 +672,17 @@ async function importDriverFile(driver: AgentDriverInfo) {
async function reinstallJre(jreKey: string) {
reinstallingJre.value = jreKey;
activeAgentOperationId.value = crypto.randomUUID();
resetAgentInstallProgress();
try {
await api.reinstallJre(jreKey);
await api.reinstallJre(jreKey, activeAgentOperationId.value);
await refreshAgents();
toast(t("driverStore.jreReinstallSuccess", { jre: jreKey }));
} catch (e: any) {
toast(t("driverStore.jreReinstallFailed", { jre: jreKey, error: e }));
} finally {
reinstallingJre.value = null;
activeAgentOperationId.value = null;
resetAgentInstallProgress();
}
}
@ -1133,25 +1187,36 @@ onMounted(async () => {
unlisten = await api.listenAgentInstallProgress((payload) => {
const incoming = payload as DriverInstallProgress;
if (!isDriverInstallProgressForOperation(incoming, activeAgentOperationId.value)) return;
const channel = driverInstallProgressChannel(incoming);
const jdbcProgressBelongsToPrestoSql = channel === "jdbc-plugin" && installing.value === PRESTOSQL_DRIVER_DB_TYPE && !isInstallingJdbcPlugin.value;
if (jdbcProgressBelongsToPrestoSql) {
// PrestoSQL is shown as a built-in driver but installs through the JDBC plugin pipeline.
agentProgress.value = updateDriverInstallProgress(agentProgress.value, incoming, "jdbc-plugin");
} else {
agentProgress.value = updateDriverInstallProgress(agentProgress.value, incoming, "agent");
jdbcPluginProgress.value = updateDriverInstallProgress(jdbcPluginProgress.value, incoming, "jdbc-plugin");
// Route its events into the per-driver map using the presto key.
updatePerDriverProgress(agentProgressByDbType, { ...incoming, db_type: PRESTOSQL_DRIVER_DB_TYPE });
} else if (channel === "agent") {
if (incoming.db_type) {
updatePerDriverProgress(agentProgressByDbType, incoming);
// Track completions for the batch counter.
if (incoming.step === "done" && upgradingAll.value) {
upgradingCompletedCount.value++;
}
} else {
// No db_type single operation (e.g. JRE reinstall).
jreReinstallProgress.value = incoming.step === "done" ? null : incoming;
}
jdbcPluginProgress.value = null; // clear any stale jdbc-only progress
} else if (channel === "jdbc-plugin") {
jdbcPluginProgress.value = incoming.step === "done" ? null : incoming;
}
if (payload.db_type && payload.total_drivers) {
upgradingCurrent.value = drivers.value.find((d) => d.db_type === payload.db_type)?.label ?? payload.db_type;
upgradingIndex.value = payload.current ?? 0;
upgradingTotal.value = payload.total_drivers ?? 0;
if (payload.total_drivers && !upgradingTotal.value) {
upgradingTotal.value = payload.total_drivers;
}
// During a batch upgrade, refresh the list as soon as each driver finishes
// (step="done") so its "Update" button disappears immediately instead of
// staying disabled until the whole batch completes (step="all-done").
// Single-driver installs (upgradingAll=false) are refreshed by runDriverInstall.
if (upgradingAll.value && payload.step === "done" && channel === "agent") {
if (upgradingAll.value && payload.step === "done" && channel === "agent" && payload.db_type) {
void refreshAgents();
}
});
@ -1205,7 +1270,7 @@ watch(driverStoreTab, (tab) => {
</SelectContent>
</Select>
</div>
<Button variant="ghost" size="sm" class="h-7 rounded-md text-xs gap-1 text-muted-foreground" :disabled="importingZip" @click="importOfflineZip">
<Button variant="ghost" size="sm" class="h-7 rounded-md text-xs gap-1 text-muted-foreground" :disabled="importingZip || installing !== null || upgradingAll || reinstallingJre !== null || queuedDriverInstalls.length > 0" @click="importOfflineZip">
<FileUp class="h-3.5 w-3.5" />
{{ importingZip ? t("driverStore.importing") : t("driverStore.importOfflinePackage") }}
</Button>
@ -1256,16 +1321,16 @@ watch(driverStoreTab, (tab) => {
</span>
<Check v-if="jre.installed" class="h-4 w-4 text-green-600" />
<span v-else class="text-xs text-muted-foreground">{{ t("driverStore.notInstalled") }}</span>
<DriverInstallProgressCircle v-if="reinstallingJre === jre.key" :percent="agentProgressNumber" :title="agentProgressTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))" />
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null" @click="reinstallJre(jre.key)">
<DriverInstallProgressCircle v-if="reinstallingJre === jre.key" :percent="getJreReinstallPercent()" :title="getJreReinstallTitle(jre.installed ? t('driverStore.reinstalling') : t('driverStore.installing'))" />
<Button v-else-if="!jre.installed" type="button" variant="default" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || importingZip" @click="reinstallJre(jre.key)">
<Download class="h-3.5 w-3.5 mr-1" />
{{ t("driverStore.install") }}
</Button>
<Button v-else-if="jre.installed" type="button" variant="outline" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null" @click="reinstallJre(jre.key)">
<Button v-else-if="jre.installed" type="button" variant="outline" size="sm" class="h-8 rounded-md text-xs" :disabled="reinstallingJre !== null || installing !== null || importingZip" @click="reinstallJre(jre.key)">
<RotateCcw class="h-3.5 w-3.5 mr-1" />
{{ t("driverStore.reinstall") }}
</Button>
<Button v-if="jre.installed" type="button" variant="ghost" size="sm" class="h-8 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null" @click="uninstallJre(jre.key)">
<Button v-if="jre.installed" type="button" variant="ghost" size="sm" class="h-8 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="reinstallingJre !== null || installing !== null || importingZip" @click="uninstallJre(jre.key)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
@ -1291,10 +1356,10 @@ watch(driverStoreTab, (tab) => {
<div class="text-sm font-semibold">{{ t("driverStore.updatesAvailableTitle") }} ({{ filteredUpdatableCount }})</div>
<p class="text-xs text-muted-foreground">{{ t("driverStore.updatesAvailableDescription") }}</p>
</div>
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || upgradingAll" @click="upgradeAll">
<Button size="sm" class="h-7 rounded-md text-xs shrink-0 ml-3" :disabled="installing !== null || upgradingAll || importingZip" @click="upgradeAll">
<Loader2 v-if="upgradingAll" class="h-3 w-3 animate-spin mr-1" />
<Download v-else class="h-3 w-3 mr-1" />
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingIndex, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
{{ upgradingAll ? t("driverStore.upgradingProgress", { current: upgradingCompletedCount, total: upgradingTotal }) : t("driverStore.upgradeAll") }}
</Button>
</div>
<div
@ -1318,12 +1383,19 @@ watch(driverStoreTab, (tab) => {
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
</div>
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button v-if="!driver.installed && isDriverQueued(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @click="removeQueuedDriverInstall(driver.db_type)">
<Button
v-if="!driver.installed && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="agentProgressNumber" :title="agentProgressTitle(t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
@ -1333,7 +1405,7 @@ watch(driverStoreTab, (tab) => {
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null"
:disabled="upgradingAll || installing !== null || importingZip"
@click="importDriverFile(driver)"
>
<FileUp class="h-3.5 w-3.5" />
@ -1344,17 +1416,17 @@ watch(driverStoreTab, (tab) => {
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="agentProgressNumber" :title="agentProgressTitle(t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
@ -1384,12 +1456,19 @@ watch(driverStoreTab, (tab) => {
<span v-if="formatSize(driver.size)" class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">{{ formatSize(driver.size) }}</span>
</div>
<div class="driver-store-agent-actions flex shrink-0 items-center gap-2">
<Button v-if="!driver.installed && isDriverQueued(driver.db_type)" size="sm" variant="outline" class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15" :disabled="upgradingAll" @click="removeQueuedDriverInstall(driver.db_type)">
<Button
v-if="!driver.installed && isDriverQueued(driver.db_type)"
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="agentProgressNumber" :title="agentProgressTitle(t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
<DriverInstallProgressCircle v-else-if="!driver.installed && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.installing'))" />
<Button v-else-if="!driver.installed" size="sm" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
<Download class="h-3 w-3 mr-1" />
{{ t("driverStore.install") }}
</Button>
@ -1399,7 +1478,7 @@ watch(driverStoreTab, (tab) => {
variant="ghost"
class="h-7 w-7 rounded-md text-xs text-muted-foreground"
:title="t('driverStore.importLocalJar')"
:disabled="upgradingAll || installing !== null"
:disabled="upgradingAll || installing !== null || importingZip"
@click="importDriverFile(driver)"
>
<FileUp class="h-3.5 w-3.5" />
@ -1410,17 +1489,17 @@ watch(driverStoreTab, (tab) => {
size="sm"
variant="outline"
class="h-7 rounded-md border-green-500/30 bg-green-500/10 text-xs text-green-700 hover:bg-green-500/15"
:disabled="upgradingAll"
:disabled="upgradingAll || importingZip"
@click="removeQueuedDriverInstall(driver.db_type)"
>
<Clock3 class="h-3 w-3 mr-1" />
{{ t("driverStore.queued") }}
</Button>
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="agentProgressNumber" :title="agentProgressTitle(t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll" @click="installDriver(driver.db_type)">
<DriverInstallProgressCircle v-else-if="driver.installed && driver.update_available && isDriverProgressActive(driver.db_type)" :percent="getAgentProgressPercent(driver.db_type)" :title="getAgentProgressTitle(driver.db_type, t('driverStore.updating'))" />
<Button v-else-if="driver.installed && driver.update_available" size="sm" variant="outline" class="h-7 rounded-md text-xs" :disabled="upgradingAll || importingZip" @click="installDriver(driver.db_type)">
{{ t("driverStore.update") }}
</Button>
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
<Button v-if="driver.installed" variant="ghost" size="sm" class="h-7 rounded-md text-xs text-muted-foreground hover:text-destructive" :disabled="installing !== null || upgradingAll || importingZip || isDriverQueued(driver.db_type)" @click="uninstallDriver(driver.db_type)">
{{ t("driverStore.uninstall") }}
</Button>
</div>
@ -1440,7 +1519,7 @@ watch(driverStoreTab, (tab) => {
</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<DriverInstallProgressCircle v-if="isInstallingJdbcPlugin" :percent="jdbcPluginProgressNumber" :title="jdbcPluginProgressTitle(t('driverStore.progressDownloadJdbcPlugin'))" />
<DriverInstallProgressCircle v-if="isInstallingJdbcPlugin" :percent="jdbcPluginProgressNumber" :title="getJdbcPluginProgressTitle(t('driverStore.progressDownloadJdbcPlugin'))" />
<span v-if="jdbcPluginStatus?.installed" class="text-xs" :class="jdbcPluginStatus.compatible ? 'text-green-600' : 'text-destructive'">
{{
jdbcPluginStatus.compatible

View File

@ -54,7 +54,7 @@ import { loadConnectionPickerView, saveConnectionPickerView, type DbPickerView }
import { normalizeRocketmqNamesrvAddr } from "@/lib/connection/rocketmqNamesrv";
import { normalizeRabbitmqAddresses } from "@/lib/connection/rabbitmqAddresses";
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressChannel, driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { driverInstallProgressChannel, driverInstallProgressPercent, isDriverInstallProgressForOperation, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityConfig, sqlServerUsesLegacyCompatibility, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { normalizeNacosEndpoint } from "@/lib/nacos/nacosAdmin";
import {
@ -175,6 +175,7 @@ const savedDatabaseInfoFingerprint = ref("");
const savedConnectionConfigFingerprint = ref("");
const showAgentInstallDialog = ref(false);
const agentInstallRunning = ref(false);
const agentInstallOperationId = ref<string | null>(null);
const agentInstallDriverKey = ref("");
const agentInstallLabel = ref("");
const agentInstallProgress = ref<DriverInstallProgress | null>(null);
@ -1385,6 +1386,7 @@ async function refreshLocalAgentDrivers(): Promise<AgentDriverInstallState[]> {
}
function beginAgentDriverInstall(driverKey: string, label: string) {
agentInstallOperationId.value = crypto.randomUUID();
agentInstallDriverKey.value = driverKey;
agentInstallLabel.value = label;
agentInstallProgress.value = null;
@ -1394,6 +1396,7 @@ function beginAgentDriverInstall(driverKey: string, label: string) {
}
function finishAgentDriverInstall() {
agentInstallOperationId.value = null;
agentInstallRunning.value = false;
agentInstallProgress.value = null;
agentInstallError.value = "";
@ -1401,6 +1404,7 @@ function finishAgentDriverInstall() {
}
function failAgentDriverInstall(error: unknown) {
agentInstallOperationId.value = null;
agentInstallRunning.value = false;
agentInstallError.value = errorMessage(error);
showAgentInstallDialog.value = true;
@ -1420,6 +1424,7 @@ function setAgentInstallDialogOpen(value: boolean) {
function handleAgentInstallProgress(payload: DriverInstallProgress) {
if (!agentInstallRunning.value || !agentInstallDriverKey.value) return;
if (driverInstallProgressChannel(payload) !== "agent") return;
if (!isDriverInstallProgressForOperation(payload, agentInstallOperationId.value)) return;
if (payload.db_type && payload.db_type !== agentInstallDriverKey.value) return;
if (payload.step === "done" || payload.step === "all-done") {
agentInstallProgress.value = null;
@ -1454,7 +1459,7 @@ async function ensureRequiredAgentDriverInstalled(config: ConnectionConfig): Pro
testResult.value = { ok: true, message: `Installing ${label} driver...` };
beginAgentDriverInstall(driverKey, label);
try {
await api.installAgent(driverKey);
await api.installAgent(driverKey, agentInstallOperationId.value ?? undefined);
await refreshLocalAgentDrivers();
finishAgentDriverInstall();
} catch (error) {
@ -1485,7 +1490,7 @@ async function installSqlServerLegacyCompatibilityComponentIfNeeded(): Promise<b
const label = t("connection.sqlServerLegacyCompatibilityComponent");
beginAgentDriverInstall(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, label);
try {
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY);
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY, agentInstallOperationId.value ?? undefined);
await refreshLocalAgentDrivers();
finishAgentDriverInstall();
} catch (error) {

View File

@ -95,25 +95,28 @@ export const clearDriverDownloadCache = forward("clearDriverDownloadCache");
export const getDriverRuntimeSummary = forward("getDriverRuntimeSummary");
export const stopDriverRuntime = forward("stopDriverRuntime");
export const restartDriverRuntime = forward("restartDriverRuntime");
export async function installAgent(dbType: string) {
export async function installAgent(dbType: string, operationId?: string) {
const backend = await getBackend();
return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource);
return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export async function upgradeAllAgents() {
export async function upgradeAllAgents(operationId?: string) {
const backend = await getBackend();
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource);
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const checkAgentUpdateBlockers = forward("checkAgentUpdateBlockers");
export const uninstallAgent = forward("uninstallAgent");
export const getAgentJavaRuntimeConfig = forward("getAgentJavaRuntimeConfig");
export const setAgentJavaRuntimeConfig = forward("setAgentJavaRuntimeConfig");
export const invalidateAgentRegistryCache = forward("invalidateAgentRegistryCache");
export const importAgentsFromZip = forward("importAgentsFromZip");
export async function importAgentsFromZip(fileOrPath: string | File, operationId?: string) {
const backend = await getBackend();
return backend.importAgentsFromZip(fileOrPath, operationId);
}
export const importAgentDriver = forward("importAgentDriver");
export const importAgentJar = importAgentDriver;
export async function reinstallJre(jreKey?: string) {
export async function reinstallJre(jreKey?: string, operationId?: string) {
const backend = await getBackend();
return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource);
return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const uninstallJre = forward("uninstallJre");
export const listenAgentInstallProgress = forward("listenAgentInstallProgress");

View File

@ -437,12 +437,12 @@ export async function restartDriverRuntime(runtimeId: string): Promise<void> {
await post("/api/agents/runtime/restart", { runtimeId });
}
export async function installAgent(dbType: string, _source?: UpdateDownloadSource): Promise<void> {
await post("/api/agents/install", { dbType });
export async function installAgent(dbType: string, _source?: UpdateDownloadSource, operationId?: string): Promise<void> {
await post("/api/agents/install", { dbType, operationId });
}
export async function upgradeAllAgents(_source?: UpdateDownloadSource): Promise<UpgradeAllAgentDriversResult> {
return post("/api/agents/upgrade-all", {});
export async function upgradeAllAgents(_source?: UpdateDownloadSource, operationId?: string): Promise<UpgradeAllAgentDriversResult> {
return post("/api/agents/upgrade-all", { operationId });
}
export async function checkAgentUpdateBlockers(_dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
@ -465,11 +465,12 @@ export async function invalidateAgentRegistryCache(): Promise<void> {
await post("/api/agents/invalidate-registry-cache", {});
}
export async function importAgentsFromZip(fileOrPath: string | File): Promise<number> {
export async function importAgentsFromZip(fileOrPath: string | File, operationId?: string): Promise<number> {
if (typeof fileOrPath === "string") {
throw new Error("Offline ZIP import in web mode requires a File object, not a file path");
}
const formData = new FormData();
if (operationId) formData.append("operationId", operationId);
formData.append("file", fileOrPath);
const res = await fetch(apiUrl("/api/agents/import-offline"), { method: "POST", body: formData });
if (!res.ok) throw new Error(await res.text());
@ -496,8 +497,8 @@ export async function importAgentDriver(dbType: string, pathOrFile: string | Fil
export const importAgentJar = importAgentDriver;
export async function reinstallJre(jreKey?: string, _source?: UpdateDownloadSource): Promise<void> {
await post("/api/agents/reinstall-jre", { jreKey });
export async function reinstallJre(jreKey?: string, _source?: UpdateDownloadSource, operationId?: string): Promise<void> {
await post("/api/agents/reinstall-jre", { jreKey, operationId });
}
export async function uninstallJre(jreKey: string): Promise<void> {

View File

@ -318,6 +318,7 @@ export interface DroppedFilePreviewSqlOptions {
export type XlsxCellValue = string | number | boolean | null;
export interface DriverInstallProgress {
operation_id?: string;
step: string;
downloaded?: number;
total?: number;
@ -1364,12 +1365,12 @@ export async function restartDriverRuntime(runtimeId: string): Promise<void> {
return invoke("restart_driver_runtime", { runtimeId });
}
export async function installAgent(dbType: string, source?: UpdateDownloadSource): Promise<void> {
return invoke("install_agent", { dbType, source });
export async function installAgent(dbType: string, source?: UpdateDownloadSource, operationId?: string): Promise<void> {
return invoke("install_agent", { dbType, source, operationId });
}
export async function upgradeAllAgents(source?: UpdateDownloadSource): Promise<UpgradeAllAgentDriversResult> {
return invoke("upgrade_all_agents", { source });
export async function upgradeAllAgents(source?: UpdateDownloadSource, operationId?: string): Promise<UpgradeAllAgentDriversResult> {
return invoke("upgrade_all_agents", { source, operationId });
}
export async function checkAgentUpdateBlockers(dbTypes: string[]): Promise<AgentUpdateBlocker[]> {
@ -1392,11 +1393,11 @@ export async function invalidateAgentRegistryCache(): Promise<void> {
return invoke("invalidate_agent_registry_cache");
}
export async function importAgentsFromZip(path: string | File): Promise<number> {
export async function importAgentsFromZip(path: string | File, operationId?: string): Promise<number> {
if (typeof path !== "string") {
throw new Error("Desktop offline ZIP import requires a local file path");
}
return invoke("import_agents_from_zip", { path });
return invoke("import_agents_from_zip", { path, operationId });
}
export async function importAgentDriver(dbType: string, path: string | File): Promise<void> {
@ -1408,8 +1409,8 @@ export async function importAgentDriver(dbType: string, path: string | File): Pr
export const importAgentJar = importAgentDriver;
export async function reinstallJre(jreKey?: string, source?: UpdateDownloadSource): Promise<void> {
return invoke("reinstall_jre", { jreKey, source });
export async function reinstallJre(jreKey?: string, source?: UpdateDownloadSource, operationId?: string): Promise<void> {
return invoke("reinstall_jre", { jreKey, source, operationId });
}
export async function uninstallJre(jreKey: string): Promise<void> {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { driverInstallProgressChannel, updateDriverInstallProgress, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { driverInstallProgressChannel, isDriverInstallProgressForOperation, isDriverInstallProgressTarget, updateDriverInstallProgress, updatePerDriverProgress, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
describe("driver install progress channels", () => {
it("classifies agent and JDBC plugin progress independently", () => {
@ -17,6 +17,16 @@ describe("driver install progress channels", () => {
expect(updateDriverInstallProgress(jdbcProgress, agentProgress, "jdbc-plugin")).toBe(jdbcProgress);
});
it("rejects progress and terminal events from another operation", () => {
expect(isDriverInstallProgressForOperation({ operation_id: "upgrade-b", step: "driver", db_type: "mysql" }, "upgrade-a")).toBe(false);
expect(isDriverInstallProgressForOperation({ operation_id: "upgrade-b", step: "all-done" }, "upgrade-a")).toBe(false);
expect(isDriverInstallProgressForOperation({ operation_id: "upgrade-a", step: "all-done" }, "upgrade-a")).toBe(true);
});
it("keeps legacy progress compatible when no operation id is emitted", () => {
expect(isDriverInstallProgressForOperation({ step: "driver", db_type: "mysql" }, "upgrade-a")).toBe(true);
});
it("allows a built-in driver backed by JDBC to consume JDBC progress explicitly", () => {
const jdbcProgress: DriverInstallProgress = { step: "jdbc-plugin", downloaded: 40, total: 100 };
@ -41,4 +51,27 @@ describe("driver install progress channels", () => {
expect(updateDriverInstallProgress(agentProgress, ambiguousDone, "agent")).toBe(agentProgress);
expect(updateDriverInstallProgress(jdbcProgress, ambiguousDone, "jdbc-plugin")).toBe(jdbcProgress);
});
it("keeps other drivers visible when one concurrent update completes", () => {
const progressByDbType: Record<string, DriverInstallProgress | null | undefined> = {};
updatePerDriverProgress(progressByDbType, { step: "driver", db_type: "mysql", downloaded: 50, total: 100 });
updatePerDriverProgress(progressByDbType, { step: "driver", db_type: "oracle", downloaded: 25, total: 100 });
updatePerDriverProgress(progressByDbType, { step: "done", db_type: "mysql" });
expect(progressByDbType.mysql).toBeNull();
expect(progressByDbType.oracle).toMatchObject({ step: "driver", downloaded: 25 });
expect(isDriverInstallProgressTarget("oracle", { installing: null, upgradingAll: true, progressMap: progressByDbType })).toBe(true);
});
it("clears all tracked progress on a batch completion event", () => {
const progressByDbType: Record<string, DriverInstallProgress | null | undefined> = {
mysql: { step: "driver", db_type: "mysql", downloaded: 50, total: 100 },
oracle: { step: "driver", db_type: "oracle", downloaded: 25, total: 100 },
};
updatePerDriverProgress(progressByDbType, { step: "all-done" });
expect(progressByDbType.mysql).toBeNull();
expect(progressByDbType.oracle).toBeNull();
});
});

View File

@ -1,20 +1,31 @@
export interface DriverInstallProgress {
operation_id?: string;
step: string;
downloaded?: number;
total?: number;
db_type?: string;
/** Number of drivers completed so far during a batch upgrade (1-based). */
completed?: number;
/** Total drivers in the batch. */
total_drivers?: number;
}
export interface DriverInstallProgressTargetState {
installing: string | null;
upgradingAll: boolean;
progress: DriverInstallProgress | null;
progressMap: Record<string, DriverInstallProgress | null | undefined>;
}
export type DriverInstallProgressChannel = "agent" | "jdbc-plugin";
const AGENT_PROGRESS_STEPS = new Set(["driver", "jre", "jre-extract", "all-done"]);
export function isDriverInstallProgressForOperation(progress: DriverInstallProgress, operationId: string | null): boolean {
// Keep accepting legacy unscoped events, while preventing another active
// install's terminal/progress event from mutating this dialog.
return !progress.operation_id || progress.operation_id === operationId;
}
export function driverInstallProgressChannel(progress: DriverInstallProgress): DriverInstallProgressChannel | null {
if (progress.step === "jdbc-plugin" || progress.step === "jdbc-plugin-extract") return "jdbc-plugin";
if (progress.db_type || AGENT_PROGRESS_STEPS.has(progress.step)) return "agent";
@ -28,7 +39,26 @@ export function updateDriverInstallProgress(current: DriverInstallProgress | nul
return incoming;
}
export function driverInstallProgressPercent(progress: DriverInstallProgress | null): number | null {
/**
* Update a per-driver progress map with an incoming event.
* Returns the updated map (mutated in-place returned for convenience).
*/
export function updatePerDriverProgress(progressMap: Record<string, DriverInstallProgress | null | undefined>, incoming: DriverInstallProgress): Record<string, DriverInstallProgress | null | undefined> {
if (incoming.step === "all-done") {
// Batch completion has no db_type, so it must be handled before routing.
for (const key of Object.keys(progressMap)) {
progressMap[key] = null;
}
return progressMap;
}
const dbType = incoming.db_type;
if (!dbType) return progressMap;
progressMap[dbType] = incoming.step === "done" ? null : incoming;
return progressMap;
}
export function driverInstallProgressPercent(progress: DriverInstallProgress | null | undefined): number | null {
if (!progress?.total || progress.total <= 0) return null;
const percent = Math.round(((progress.downloaded ?? 0) / progress.total) * 100);
return Math.min(100, Math.max(0, percent));
@ -36,7 +66,11 @@ export function driverInstallProgressPercent(progress: DriverInstallProgress | n
export function isDriverInstallProgressTarget(dbType: string, state: DriverInstallProgressTargetState): boolean {
if (state.installing === dbType) return true;
return state.upgradingAll && state.progress?.db_type === dbType;
if (!state.upgradingAll) return false;
// During batch upgrade, check the per-driver map — the driver is "active"
// if it has a (non-null, non-"done") progress entry.
const progress = state.progressMap[dbType];
return progress !== null && progress !== undefined;
}
export function addDriverInstallQueue(queue: string[], dbType: string, activeDbType: string | null): string[] {

View File

@ -2,6 +2,7 @@ use std::ffi::OsStr;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
@ -435,6 +436,18 @@ pub struct AgentManager {
pub(crate) connection_runtimes: Mutex<
std::collections::HashMap<String, std::sync::Arc<tokio::sync::OnceCell<std::sync::Arc<AgentRuntimeClient>>>>,
>,
/// Serializes `load_state` → modify → `save_state` to prevent lost updates
/// when multiple driver installs run concurrently.
pub(crate) state_lock: StdMutex<()>,
/// Per-JRE-key install locks so that concurrent driver installs sharing the
/// same JRE download it only once (DCL pattern: lock → re-check installed → download).
pub(crate) jre_install_locks: Mutex<std::collections::HashMap<String, Arc<Mutex<()>>>>,
/// Per-driver locks serialize install, import, and uninstall operations
/// targeting the same on-disk agent files.
pub(crate) driver_operation_locks: Mutex<std::collections::HashMap<String, Arc<Mutex<()>>>>,
/// Driver operations may run concurrently, but JRE replacement/removal
/// must exclude them until their dependent driver state is persisted.
pub(crate) installation_operation_lock: tokio::sync::RwLock<()>,
}
impl Default for AgentManager {
@ -460,6 +473,10 @@ impl AgentManager {
app_version: app_version.into(),
daemons: Mutex::new(std::collections::HashMap::new()),
connection_runtimes: Mutex::new(std::collections::HashMap::new()),
state_lock: StdMutex::new(()),
jre_install_locks: Mutex::new(std::collections::HashMap::new()),
driver_operation_locks: Mutex::new(std::collections::HashMap::new()),
installation_operation_lock: tokio::sync::RwLock::new(()),
};
mgr.migrate_legacy_jre();
mgr.cleanup_pending_jre_dirs();
@ -467,6 +484,19 @@ impl AgentManager {
mgr
}
/// Atomically load, modify, and persist the installation state.
///
/// Keep the closure free of async work: this lock exists specifically to
/// prevent one installation operation from saving an older snapshot over
/// another operation's successful update.
pub fn mutate_state<T>(&self, mutate: impl FnOnce(&mut AgentState) -> T) -> Result<T, String> {
let _guard = self.state_lock.lock().map_err(|_| "Agent installation state lock was poisoned".to_string())?;
let mut state = self.load_state();
let result = mutate(&mut state);
self.save_state(&state)?;
Ok(result)
}
fn migrate_legacy_jre(&self) {
let legacy = self.base_dir.join("jre");
let versioned = self.jre_dir(DEFAULT_JRE_KEY);
@ -480,25 +510,26 @@ impl AgentManager {
/// removals are pruned from the persisted state. Failures are kept for the
/// next launch and never block startup. (Issue #1100, D6.)
fn cleanup_pending_jre_dirs(&self) {
let mut state = self.load_state();
if state.pending_jre_cleanup.is_empty() {
if self.load_state().pending_jre_cleanup.is_empty() {
return;
}
let mut remaining = Vec::new();
for path in std::mem::take(&mut state.pending_jre_cleanup) {
if !path.exists() {
continue;
}
match std::fs::remove_dir_all(&path) {
Ok(()) => log::info!("Cleaned up pending JRE stash: {}", path.display()),
Err(err) => {
log::warn!("Pending JRE cleanup failed for {}: {err}", path.display());
remaining.push(path);
if let Err(err) = self.mutate_state(|state| {
let mut remaining = Vec::new();
for path in std::mem::take(&mut state.pending_jre_cleanup) {
if !path.exists() {
continue;
}
match std::fs::remove_dir_all(&path) {
Ok(()) => log::info!("Cleaned up pending JRE stash: {}", path.display()),
Err(err) => {
log::warn!("Pending JRE cleanup failed for {}: {err}", path.display());
remaining.push(path);
}
}
}
}
state.pending_jre_cleanup = remaining;
if let Err(err) = self.save_state(&state) {
state.pending_jre_cleanup = remaining;
}) {
log::warn!("Failed to persist post-cleanup AgentState: {err}");
}
}

View File

@ -1,8 +1,11 @@
use std::hash::{Hash, Hasher};
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use futures::{stream, StreamExt};
use crate::agent_catalog;
use crate::agent_manager::{
AgentDriverInfo, AgentManager, AgentRegistry, InstalledDriver, JavaRuntimeMode, DEFAULT_JRE_KEY,
@ -18,6 +21,10 @@ const JRE_REMOVE_ATTEMPTS: usize = if cfg!(windows) { 6 } else { 1 };
/// Exponential-ish backoff between retries. Total wait ≈ 1.55s on Windows.
const JRE_REMOVE_BACKOFF_MS: &[u64] = &[50, 100, 200, 400, 400, 400];
/// Keep batch updates concurrent without allowing a large registry to exhaust
/// the download server, local disk, or the application's file descriptors.
const MAX_CONCURRENT_AGENT_UPDATES: usize = 4;
/// Delete an old JRE directory, retrying on Windows to cover the daemon-exit
/// and AV-scan release window. Returns the original `std::io::Error` when all
/// retries fail so callers can decide whether to fall back to rename-stash.
@ -93,7 +100,7 @@ fn stash_old_jre_dir(path: &Path) -> std::io::Result<PathBuf> {
/// path (Some) if the rename fallback was used so the caller can persist it
/// for startup cleanup, or None if the directory was deleted outright (or
/// did not exist).
fn replace_old_jre_dir(am: &AgentManager, path: &Path) -> Result<Option<PathBuf>, String> {
fn replace_old_jre_dir(path: &Path) -> Result<Option<PathBuf>, String> {
match remove_jre_dir_with_retry(path) {
Ok(()) => Ok(None),
Err(remove_err) => {
@ -102,13 +109,8 @@ fn replace_old_jre_dir(am: &AgentManager, path: &Path) -> Result<Option<PathBuf>
match stash_old_jre_dir(path) {
Ok(stash) => {
log::warn!("remove_dir_all failed, stashed old JRE at {} ({remove_err})", stash.display());
// Persist immediately so a crash before extraction
// still leaves the stash recorded for cleanup.
let mut state = am.load_state();
state.pending_jre_cleanup.push(stash.clone());
if let Err(save_err) = am.save_state(&state) {
log::warn!("Failed to persist pending_jre_cleanup: {save_err}");
}
// The caller will persist this stash under
// state_lock after extraction succeeds.
Ok(Some(stash))
}
Err(rename_err) => {
@ -122,7 +124,6 @@ fn replace_old_jre_dir(am: &AgentManager, path: &Path) -> Result<Option<PathBuf>
}
#[cfg(not(windows))]
{
let _ = am; // silence unused warning on POSIX
Err(format_jre_dir_remove_error(path, &remove_err))
}
}
@ -138,6 +139,8 @@ static REGISTRY_CACHE: std::sync::LazyLock<
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct AgentProgressEvent {
#[serde(skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
pub step: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub downloaded: Option<u64>,
@ -165,7 +168,15 @@ pub struct UpgradeAllAgentDriversResult {
impl AgentProgressEvent {
pub fn step(step: impl Into<String>) -> Self {
Self { step: step.into(), downloaded: None, total: None, db_type: None, current: None, total_drivers: None }
Self {
operation_id: None,
step: step.into(),
downloaded: None,
total: None,
db_type: None,
current: None,
total_drivers: None,
}
}
pub fn transfer(step: impl Into<String>, downloaded: u64, total: u64) -> Self {
@ -178,6 +189,11 @@ impl AgentProgressEvent {
self.total_drivers = total_drivers;
self
}
pub fn with_operation_id(mut self, operation_id: &str) -> Self {
self.operation_id = Some(operation_id.to_string());
self
}
}
pub fn build_agent_list(am: &AgentManager, registry: Option<&AgentRegistry>) -> Vec<AgentDriverInfo> {
@ -303,27 +319,32 @@ pub fn find_local_agent_jar(db_type: &str) -> Option<PathBuf> {
}
pub fn install_local_agent(am: &AgentManager, db_type: &str, source: PathBuf) -> Result<(), String> {
install_local_agent_file(am, db_type, &source)?;
am.mutate_state(|state| record_local_agent_install(state, db_type, DEFAULT_JRE_KEY))
}
fn install_local_agent_file(am: &AgentManager, db_type: &str, source: &Path) -> Result<(), String> {
let jar_path = am.driver_jar_path(db_type);
let parent = jar_path.parent().ok_or_else(|| format!("Invalid driver path: {}", jar_path.display()))?;
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
let staging_path = parent.join(format!(".agent-jar-import-{}", uuid::Uuid::new_v4()));
std::fs::copy(&source, &staging_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?;
std::fs::copy(source, &staging_path).map_err(|e| format!("Failed to copy local agent jar: {e}"))?;
if !is_valid_agent_jar(&staging_path) {
std::fs::remove_file(&staging_path).ok();
return Err(format!("Local agent jar is invalid or corrupt: {}", source.display()));
}
replace_imported_agent_file(&staging_path, &jar_path)?;
replace_imported_agent_file(&staging_path, &jar_path)
}
let mut local_state = am.load_state();
local_state.installed_drivers.insert(
fn record_local_agent_install(state: &mut crate::agent_manager::AgentState, db_type: &str, jre_key: &str) {
state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: "0.1.0-local".to_string(),
installed_at: chrono::Utc::now().to_rfc3339(),
jre: DEFAULT_JRE_KEY.to_string(),
jre: jre_key.to_string(),
},
);
am.save_state(&local_state)
}
fn is_valid_agent_jar(path: &Path) -> bool {
@ -423,27 +444,45 @@ pub async fn upgrade_all_agent_drivers_from(
progress: impl Fn(AgentProgressEvent),
) -> Result<UpgradeAllAgentDriversResult, String> {
let registry = fetch_registry_from(source).await?;
let agents = build_agent_list(am, Some(&registry));
let updatable: Vec<&AgentDriverInfo> = agents.iter().filter(|agent| agent.update_available).collect();
let total = updatable.len() as u32;
let mut result = UpgradeAllAgentDriversResult::default();
upgrade_all_agent_drivers_with_registry(am, &registry, source, &progress).await
}
for (index, agent) in updatable.iter().enumerate() {
match install_agent_driver_from_registry(
async fn upgrade_all_agent_drivers_with_registry(
am: &AgentManager,
registry: &AgentRegistry,
source: DownloadSource,
progress: &impl Fn(AgentProgressEvent),
) -> Result<UpgradeAllAgentDriversResult, String> {
let agents = build_agent_list(am, Some(registry));
let updatable: Vec<String> =
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
let total = updatable.len() as u32;
// Run independent driver installs concurrently, with a fixed upper bound
// so a large registry cannot saturate download and file-system resources.
let installs = updatable.into_iter().enumerate().map(|(index, db_type)| async move {
let result = install_agent_driver_from_registry_locked(
am,
&registry,
registry,
source,
&agent.db_type,
&progress,
&db_type,
progress,
Some((index + 1) as u32),
Some(total),
)
.await
{
.await;
(db_type, result)
});
let outcomes = stream::iter(installs).buffer_unordered(MAX_CONCURRENT_AGENT_UPDATES).collect::<Vec<_>>().await;
let mut result = UpgradeAllAgentDriversResult::default();
for (db_type, outcome) in outcomes {
match outcome {
Ok(()) => result.upgraded += 1,
Err(error) => {
log::warn!("Failed to update {} agent driver: {}", agent.db_type, error);
result.failed.push(AgentDriverUpdateIssue { db_type: agent.db_type.clone(), error });
log::warn!("Failed to update {} agent driver: {}", db_type, error);
result.failed.push(AgentDriverUpdateIssue { db_type, error });
}
}
}
@ -452,7 +491,20 @@ pub async fn upgrade_all_agent_drivers_from(
Ok(result)
}
async fn driver_operation_lock(am: &AgentManager, db_type: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = am.driver_operation_locks.lock().await;
locks.entry(db_type.to_string()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone()
}
async fn jre_operation_lock(am: &AgentManager, jre_key: &str) -> Arc<tokio::sync::Mutex<()>> {
let mut locks = am.jre_install_locks.lock().await;
locks.entry(jre_key.to_string()).or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))).clone()
}
pub async fn uninstall_agent_driver(am: &AgentManager, db_type: &str) -> Result<(), String> {
let _installation_guard = am.installation_operation_lock.read().await;
let driver_lock = driver_operation_lock(am, db_type).await;
let _driver_guard = driver_lock.lock().await;
prune_driver_download_cache(am, db_type)?;
let jar_path = am.driver_jar_path(db_type);
if jar_path.exists() {
@ -463,9 +515,7 @@ pub async fn uninstall_agent_driver(am: &AgentManager, db_type: &str) -> Result<
std::fs::remove_dir_all(driver_dir).map_err(|err| err.to_string())?;
}
}
let mut local_state = am.load_state();
local_state.installed_drivers.remove(db_type);
am.save_state(&local_state)?;
am.mutate_state(|state| state.installed_drivers.remove(db_type))?;
am.stop_daemon_by_key(db_type).await;
Ok(())
}
@ -475,6 +525,11 @@ pub fn clear_agent_download_cache(am: &AgentManager) -> Result<(), String> {
}
pub async fn uninstall_agent_jre(am: &AgentManager, jre_key: &str) -> Result<(), String> {
// Keep the dependency check and removal atomic with respect to driver
// installs/uninstalls that may add or remove a dependency on this JRE.
let _installation_guard = am.installation_operation_lock.write().await;
let jre_lock = jre_operation_lock(am, jre_key).await;
let _jre_guard = jre_lock.lock().await;
let local_state = am.load_state();
let dependents: Vec<&str> = local_state
.installed_drivers
@ -492,9 +547,7 @@ pub async fn uninstall_agent_jre(am: &AgentManager, jre_key: &str) -> Result<(),
if let Err(err) = remove_jre_dir_with_retry(&jre_dir) {
return Err(format_jre_dir_remove_error(&jre_dir, &err));
}
let mut local_state = am.load_state();
local_state.jre_versions.remove(jre_key);
am.save_state(&local_state)?;
am.mutate_state(|state| state.jre_versions.remove(jre_key))?;
Ok(())
}
@ -512,6 +565,11 @@ pub async fn reinstall_agent_jre_from(
source: DownloadSource,
progress: impl Fn(AgentProgressEvent),
) -> Result<(), String> {
// Replacing a JRE must not race a driver operation that is using or about
// to persist a dependency on the same runtime.
let _installation_guard = am.installation_operation_lock.write().await;
let jre_lock = jre_operation_lock(am, jre_key).await;
let _jre_guard = jre_lock.lock().await;
let registry = fetch_registry_from(source).await?;
let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?;
let platform = AgentManager::current_platform();
@ -540,32 +598,33 @@ pub async fn reinstall_agent_jre_from(
// handles on Windows (Issue #1100). Falls back to a rename-stash if the
// directory still cannot be removed.
am.stop_daemons().await;
replace_old_jre_dir(am, &jre_dir)?;
let stash = replace_old_jre_dir(&jre_dir)?;
persist_pending_jre_cleanup(am, stash.as_ref()).await?;
extract_tar_gz(&jre_archive, &jre_dir)?;
std::fs::remove_file(&jre_archive).ok();
let mut local_state = am.load_state();
local_state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone());
am.save_state(&local_state)?;
am.mutate_state(|state| state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone()))?;
cleanup_jre_download_cache_after_success(am, jre_key);
progress(AgentProgressEvent::step("done"));
Ok(())
}
pub fn import_agents_from_zip(
pub async fn import_agents_from_zip(
am: &AgentManager,
zip_path: &Path,
progress: impl Fn(AgentProgressEvent),
) -> Result<OfflineImportResult, String> {
import_offline_zip(am, zip_path, |p| {
progress(AgentProgressEvent {
operation_id: None,
step: p.step,
downloaded: Some(p.current as u64),
total: Some(p.total as u64),
db_type: Some(p.label),
db_type: p.db_type,
current: Some(p.current),
total_drivers: Some(p.total),
});
})
.await
}
async fn install_agent_driver_with_batch(
@ -575,6 +634,35 @@ async fn install_agent_driver_with_batch(
progress: &impl Fn(AgentProgressEvent),
current: Option<u32>,
total_drivers: Option<u32>,
) -> Result<(), String> {
let _installation_guard = am.installation_operation_lock.read().await;
let driver_lock = driver_operation_lock(am, db_type).await;
let _driver_guard = driver_lock.lock().await;
install_agent_driver_with_batch_unlocked(am, db_type, source, progress, current, total_drivers).await
}
async fn install_agent_driver_from_registry_locked(
am: &AgentManager,
registry: &AgentRegistry,
source: DownloadSource,
db_type: &str,
progress: &impl Fn(AgentProgressEvent),
current: Option<u32>,
total_drivers: Option<u32>,
) -> Result<(), String> {
let _installation_guard = am.installation_operation_lock.read().await;
let driver_lock = driver_operation_lock(am, db_type).await;
let _driver_guard = driver_lock.lock().await;
install_agent_driver_from_registry(am, registry, source, db_type, progress, current, total_drivers).await
}
async fn install_agent_driver_with_batch_unlocked(
am: &AgentManager,
db_type: &str,
source: DownloadSource,
progress: &impl Fn(AgentProgressEvent),
current: Option<u32>,
total_drivers: Option<u32>,
) -> Result<(), String> {
match fetch_registry_from(source).await {
Ok(registry) => {
@ -623,13 +711,30 @@ async fn ensure_jre_from_registry(
current: Option<u32>,
total_drivers: Option<u32>,
) -> Result<(), String> {
// Fast path: already installed — return immediately without acquiring the
// per-JRE lock. The lock is only needed when a download + extract may be
// required.
if !jre_needs_install(am, registry, jre_key) {
return Ok(());
}
// Acquire (or create) the per-JRE-key mutex so that concurrent driver
// installs sharing the same JRE download it exactly once.
let lock = jre_operation_lock(am, jre_key).await;
let _jre_guard = lock.lock().await;
// Double-check: the previous lock holder may have already installed.
if !jre_needs_install(am, registry, jre_key) {
return Ok(());
}
let jre_info = registry.resolve_jre(jre_key).ok_or_else(|| format!("No JRE definition for version: {jre_key}"))?;
let platform = AgentManager::current_platform();
let platform_jre = jre_info
.platforms
.get(platform)
.ok_or_else(|| format!("No JRE {jre_key} available for platform: {platform}"))?;
let jre_archive = am.base_dir().join("jre-download.tar.gz");
let jre_archive = am.base_dir().join(format!("jre-{}-download.tar.gz", jre_key));
progress(AgentProgressEvent::transfer("jre", 0, platform_jre.size).with_batch(
Some(db_type),
current,
@ -652,15 +757,67 @@ async fn ensure_jre_from_registry(
.await?;
progress(AgentProgressEvent::transfer("jre-extract", 0, 0).with_batch(Some(db_type), current, total_drivers));
let jre_dir = am.jre_dir(jre_key);
// Stop daemons first (Windows ERROR_ACCESS_DENIED, Issue #1100).
am.stop_daemons().await;
replace_old_jre_dir(am, &jre_dir)?;
// Stop only daemons that use this JRE before replacing its directory
// (Windows ERROR_ACCESS_DENIED, Issue #1100). In a concurrent
// upgrade-all this avoids killing unrelated daemons mid-install.
stop_daemons_using_jre(am, jre_key).await;
let stash = replace_old_jre_dir(&jre_dir)?;
// Persist the stash path *before* extraction so that a crash during
// extract_tar_gz (or a process kill) doesn't leave the renamed-stash
// directory as an orphan that never gets cleaned up.
persist_pending_jre_cleanup(am, stash.as_ref()).await?;
extract_tar_gz(&jre_archive, &jre_dir)?;
std::fs::remove_file(&jre_archive).ok();
cleanup_jre_download_cache_after_success(am, jre_key);
// Persist the JRE version after extraction succeeds, while still holding
// the per-JRE lock. This guarantees the DCL in another task's
// jre_needs_install() sees the installed version and skips download.
am.mutate_state(|state| state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone()))?;
Ok(())
}
/// Stop daemons whose installed driver lists `jre_key` as its runtime.
async fn stop_daemons_using_jre(am: &AgentManager, jre_key: &str) {
let state = am.load_state();
for (db_type, driver) in &state.installed_drivers {
if driver.jre == jre_key {
am.stop_daemon_by_key(db_type).await;
}
}
}
/// Record a rename-stashed JRE before extraction so startup can clean it up
/// even if the process exits mid-install.
async fn persist_pending_jre_cleanup(am: &AgentManager, stash: Option<&PathBuf>) -> Result<(), String> {
let Some(stash_path) = stash else {
return Ok(());
};
am.mutate_state(|state| {
if !state.pending_jre_cleanup.contains(stash_path) {
state.pending_jre_cleanup.push(stash_path.clone());
}
})
}
async fn persist_local_agent_install_state(
am: &AgentManager,
db_type: &str,
jre_key: &str,
jre_version: Option<&str>,
) -> Result<(), String> {
am.mutate_state(|state| {
if let Some(version) = jre_version {
state.jre_versions.insert(jre_key.to_string(), version.to_string());
}
record_local_agent_install(state, db_type, jre_key);
})
}
async fn install_local_agent_with_registry_jre(
am: &AgentManager,
registry: &AgentRegistry,
@ -675,12 +832,16 @@ async fn install_local_agent_with_registry_jre(
if jre_needs_install(am, registry, jre_key) {
ensure_jre_from_registry(am, registry, source, jre_key, db_type, progress, current, total_drivers).await?;
}
install_local_agent(am, db_type, local_jar)?;
if let Some(jre_info) = registry.resolve_jre(jre_key) {
let mut local_state = am.load_state();
local_state.jre_versions.insert(jre_key.to_string(), jre_info.version.clone());
am.save_state(&local_state)?;
}
install_local_agent_file(am, db_type, &local_jar)?;
// This fallback can run for several drivers during upgrade-all. Keep its
// driver and JRE updates in one state_lock-protected transaction.
persist_local_agent_install_state(
am,
db_type,
jre_key,
registry.resolve_jre(jre_key).map(|jre| jre.version.as_str()),
)
.await?;
am.stop_daemon_by_key(db_type).await;
progress(AgentProgressEvent::step("done").with_batch(Some(db_type), current, total_drivers));
Ok(())
@ -765,21 +926,21 @@ async fn install_agent_driver_from_registry(
std::fs::remove_file(am.driver_native_path(db_type)).ok();
}
let mut local_state = am.load_state();
if requires_java_runtime {
if let Some(jre_info) = registry.resolve_jre(jre_key) {
local_state.jre_versions.insert(jre_key.clone(), jre_info.version.clone());
am.mutate_state(|state| {
if requires_java_runtime {
if let Some(jre_info) = registry.resolve_jre(jre_key) {
state.jre_versions.insert(jre_key.clone(), jre_info.version.clone());
}
}
}
local_state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: driver.version.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
jre: jre_key.clone(),
},
);
am.save_state(&local_state)?;
state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: driver.version.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
jre: jre_key.clone(),
},
);
})?;
am.stop_daemon_by_key(db_type).await;
cleanup_driver_download_cache_after_success(am, db_type);
progress(AgentProgressEvent::step("done").with_batch(Some(db_type), current, total_drivers));
@ -1234,7 +1395,12 @@ pub struct OfflineImportProgress {
pub step: String,
pub current: u32,
pub total: u32,
/// Display label for the current item (e.g. "MySQL", "JRE 21.0.12").
pub label: String,
/// The real database-type key (e.g. "mysql"), used by the frontend for
/// per-driver progress routing. `None` for JRE-only steps.
#[serde(skip_serializing_if = "Option::is_none")]
pub db_type: Option<String>,
}
#[derive(Debug, Clone)]
@ -1265,11 +1431,16 @@ pub fn inspect_offline_zip(zip_path: &Path) -> Result<OfflineImportPlan, String>
})
}
pub fn import_offline_zip(
pub async fn import_offline_zip(
am: &AgentManager,
zip_path: &Path,
progress: impl Fn(OfflineImportProgress),
) -> Result<OfflineImportResult, String> {
// Offline import can touch both JRE and driver directories — hold an
// exclusive installation-operation lock so that concurrent driver installs,
// JRE installs, Upgrade All, and uninstall operations are serialised.
let _installation_guard = am.installation_operation_lock.write().await;
let file = std::fs::File::open(zip_path).map_err(|e| format!("Failed to open ZIP file: {e}"))?;
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("Invalid ZIP file: {e}"))?;
@ -1298,7 +1469,13 @@ pub fn import_offline_zip(
continue;
}
progress(OfflineImportProgress { step: "jre-extract".into(), current, total, label: format!("JRE {jre_key}") });
progress(OfflineImportProgress {
step: "jre-extract".into(),
current,
total,
label: format!("JRE {jre_key}"),
db_type: None,
});
let mut entry = archive.by_name(entry_name).map_err(|e| format!("Failed to read {entry_name}: {e}"))?;
let tmp_archive = am.base_dir().join(format!("jre-offline-{jre_key}.tar.gz"));
@ -1352,6 +1529,7 @@ pub fn import_offline_zip(
current,
total,
label: agent_catalog::label_for_key(db_type).unwrap_or(db_type).to_string(),
db_type: Some(db_type.clone()),
});
let driver_path = if *is_native { am.driver_native_path(db_type) } else { am.driver_jar_path(db_type) };
@ -1396,7 +1574,23 @@ pub fn import_offline_zip(
result.drivers_installed.push(db_type.clone());
}
am.save_state(&local_state)?;
am.mutate_state(|state| {
for jre_key in &result.jre_installed {
if let Some(version) = local_state.jre_versions.get(jre_key) {
state.jre_versions.insert(jre_key.clone(), version.clone());
}
}
for path in &local_state.pending_jre_cleanup {
if !state.pending_jre_cleanup.contains(path) {
state.pending_jre_cleanup.push(path.clone());
}
}
for db_type in &result.drivers_installed {
if let Some(driver) = local_state.installed_drivers.get(db_type) {
state.installed_drivers.insert(db_type.clone(), driver.clone());
}
}
})?;
Ok(result)
}
@ -1541,7 +1735,14 @@ fn extract_tar_gz(archive: &Path, dest: &Path) -> Result<(), String> {
Ok(())
}
pub fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path) -> Result<(), String> {
pub async fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path) -> Result<(), String> {
// Manual imports replace the same artifact paths as downloads. Reuse the
// install operation and per-driver locks so an import cannot race an
// install, Upgrade All, or uninstall for this driver.
let _installation_guard = am.installation_operation_lock.read().await;
let driver_lock = driver_operation_lock(am, db_type).await;
let _driver_guard = driver_lock.lock().await;
if !source_path.is_file() {
return Err(format!("File not found: {}", source_path.display()));
}
@ -1562,20 +1763,20 @@ pub fn import_agent_driver(am: &AgentManager, db_type: &str, source_path: &Path)
replace_imported_agent_file(&staging_path, &native_path)?;
std::fs::remove_file(am.driver_jar_path(db_type)).ok();
let mut local_state = am.load_state();
local_state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: "0.1.0-local".to_string(),
installed_at: chrono::Utc::now().to_rfc3339(),
jre: DEFAULT_JRE_KEY.to_string(),
},
);
am.save_state(&local_state)
am.mutate_state(|state| {
state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: "0.1.0-local".to_string(),
installed_at: chrono::Utc::now().to_rfc3339(),
jre: DEFAULT_JRE_KEY.to_string(),
},
);
})
}
pub fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> {
import_agent_driver(am, db_type, jar_path)
pub async fn import_agent_jar(am: &AgentManager, db_type: &str, jar_path: &Path) -> Result<(), String> {
import_agent_driver(am, db_type, jar_path).await
}
fn replace_imported_agent_file(staging_path: &Path, target_path: &Path) -> Result<(), String> {
@ -1804,13 +2005,23 @@ mod agent_download_url_tests {
#[cfg(test)]
mod agent_registry_install_tests {
use super::*;
use crate::agent_manager::{ArtifactInfo, DriverInfo, JavaRuntimeConfig};
use crate::agent_manager::{ArtifactInfo, DriverInfo, InstalledDriver, JavaRuntimeConfig, JreInfo};
fn test_manager(name: &str) -> AgentManager {
let dir = std::env::temp_dir().join(format!("dbx-agent-registry-install-{name}-{}", uuid::Uuid::new_v4()));
AgentManager::new_with_base_dir(dir)
}
fn write_test_agent_jar(path: &Path) {
use std::io::Write;
let file = std::fs::File::create(path).unwrap();
let mut archive = zip::ZipWriter::new(file);
archive.start_file("META-INF/MANIFEST.MF", zip::write::SimpleFileOptions::default()).unwrap();
archive.write_all(b"Manifest-Version: 1.0\nMain-Class: com.dbx.Agent\n").unwrap();
archive.finish().unwrap();
}
fn registry_with_native_and_legacy_jar(
db_type: &str,
version: &str,
@ -1871,6 +2082,57 @@ mod agent_registry_install_tests {
cache_path
}
fn registry_with_jre(jre_key: &str, version: &str, url: &str, size: u64) -> AgentRegistry {
AgentRegistry {
jre: None,
jres: [(
jre_key.to_string(),
JreInfo {
version: version.to_string(),
platforms: [(
AgentManager::current_platform().to_string(),
ArtifactInfo { url: url.to_string(), size },
)]
.into_iter()
.collect(),
},
)]
.into_iter()
.collect(),
drivers: std::collections::HashMap::new(),
}
}
fn build_jre_archive(am: &AgentManager, jre_key: &str) -> Vec<u8> {
let archive_root = am.base_dir().join("jre-test-archive");
let payload = archive_root.join("payload");
let java_path = am.jre_java_path(jre_key);
let relative_java_path = java_path.strip_prefix(am.jre_dir(jre_key)).unwrap();
let java_path = payload.join(relative_java_path);
std::fs::create_dir_all(java_path.parent().unwrap()).unwrap();
std::fs::write(java_path, b"java").unwrap();
let archive = archive_root.join("runtime.tar.gz");
let status = crate::process::new_std_command("tar")
.args(["czf", &archive.to_string_lossy(), "-C", &archive_root.to_string_lossy(), "payload"])
.status()
.unwrap();
assert!(status.success());
std::fs::read(archive).unwrap()
}
fn write_cached_jre_download(am: &AgentManager, jre_key: &str, version: &str, url: &str, archive: &[u8]) {
let dest = am.base_dir().join(format!("jre-{jre_key}-download.tar.gz"));
let cache_path = cached_download_path(
am,
url,
archive.len() as u64,
Some(CacheIdentity::Jre { key: jre_key, version }),
&dest,
);
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
std::fs::write(cache_path, archive).unwrap();
}
#[tokio::test]
async fn registry_install_accepts_native_driver_with_legacy_jar_fallback() {
let manager = test_manager("native-with-jar-fallback");
@ -1908,6 +2170,263 @@ mod agent_registry_install_tests {
.any(|event| event.step == "done" && event.db_type.as_deref() == Some(db_type)));
}
#[tokio::test]
async fn batch_upgrade_preserves_successful_state_and_reports_independent_failure() {
let manager = test_manager("batch-upgrade");
let oracle_url = "https://example.com/dbx-agent-oracle";
let dameng_url = "https://example.com/dbx-agent-dameng";
let kingbase_url = "https://example.com/dbx-agent-kingbase.jar";
let oracle_bytes = b"oracle-native-agent";
let dameng_bytes = b"dameng-native-agent";
let corrupt_jar = b"not-a-jar";
let mut registry =
registry_with_native_and_legacy_jar("oracle", "2.0.0", oracle_url, oracle_bytes.len() as u64);
registry.drivers.extend(
registry_with_native_and_legacy_jar("dameng", "2.0.0", dameng_url, dameng_bytes.len() as u64).drivers,
);
registry.drivers.extend(registry_with_jar("kingbase", "2.0.0", kingbase_url, corrupt_jar.len() as u64).drivers);
let mut state = manager.load_state();
state.java_runtime = JavaRuntimeConfig { mode: JavaRuntimeMode::System, custom_java_path: None };
for (db_type, version) in [("oracle", "1.0.0"), ("dameng", "1.0.0"), ("kingbase", "1.0.0")] {
state.installed_drivers.insert(
db_type.to_string(),
InstalledDriver {
version: version.to_string(),
installed_at: "2026-01-01T00:00:00Z".to_string(),
jre: DEFAULT_JRE_KEY.to_string(),
},
);
}
manager.save_state(&state).unwrap();
write_cached_driver_download(
&manager,
"oracle",
"2.0.0",
oracle_url,
&manager.driver_native_path("oracle"),
oracle_bytes,
);
write_cached_driver_download(
&manager,
"dameng",
"2.0.0",
dameng_url,
&manager.driver_native_path("dameng"),
dameng_bytes,
);
write_cached_driver_download(
&manager,
"kingbase",
"2.0.0",
kingbase_url,
&manager.driver_jar_path("kingbase"),
corrupt_jar,
);
let events = std::sync::Mutex::new(Vec::new());
let progress = |event| events.lock().unwrap().push(event);
let result = upgrade_all_agent_drivers_with_registry(&manager, &registry, DownloadSource::Official, &progress)
.await
.unwrap();
assert_eq!(result.upgraded, 2);
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].db_type, "kingbase");
let state = manager.load_state();
assert_eq!(state.installed_drivers["oracle"].version, "2.0.0");
assert_eq!(state.installed_drivers["dameng"].version, "2.0.0");
assert_eq!(state.installed_drivers["kingbase"].version, "1.0.0");
assert_eq!(events.lock().unwrap().iter().filter(|event| event.step == "done").count(), 2);
}
#[tokio::test]
async fn shared_jre_is_not_downloaded_again_after_the_first_install_persists_its_version() {
let manager = test_manager("shared-jre-deduplication");
let jre_key = DEFAULT_JRE_KEY;
let version = "21.0.12";
let url = "https://example.com/dbx-jre.tar.gz";
let archive = build_jre_archive(&manager, jre_key);
let registry = registry_with_jre(jre_key, version, url, archive.len() as u64);
let events = std::sync::Mutex::new(Vec::new());
let progress = |event| events.lock().unwrap().push(event);
write_cached_jre_download(&manager, jre_key, version, url, &archive);
ensure_jre_from_registry(
&manager,
&registry,
DownloadSource::Official,
jre_key,
"oracle",
&progress,
Some(1),
Some(2),
)
.await
.unwrap();
// The successful install cleans its cache. Re-add it so the old
// implementation fails deterministically by consuming it again.
write_cached_jre_download(&manager, jre_key, version, url, &archive);
ensure_jre_from_registry(
&manager,
&registry,
DownloadSource::Official,
jre_key,
"dameng",
&progress,
Some(2),
Some(2),
)
.await
.unwrap();
assert_eq!(manager.load_state().jre_versions[jre_key], version);
assert!(events
.lock()
.unwrap()
.iter()
.any(|event| event.step == "jre" && event.db_type.as_deref() == Some("oracle")));
assert!(!events
.lock()
.unwrap()
.iter()
.any(|event| event.step == "jre" && event.db_type.as_deref() == Some("dameng")));
}
#[tokio::test]
async fn stash_is_recorded_before_jre_extraction() {
let manager = test_manager("stash-before-extract");
let stash = manager.base_dir().join("jre-21.old-test");
persist_pending_jre_cleanup(&manager, Some(&stash)).await.unwrap();
assert_eq!(manager.load_state().pending_jre_cleanup, vec![stash]);
}
#[test]
fn concurrent_local_agent_state_updates_preserve_each_driver() {
let manager = test_manager("concurrent-local-agent-state");
let start = std::sync::Arc::new(std::sync::Barrier::new(3));
std::thread::scope(|scope| {
for db_type in ["oracle", "dameng"] {
let start = start.clone();
let manager = &manager;
scope.spawn(move || {
start.wait();
manager
.mutate_state(|state| {
state.jre_versions.insert(DEFAULT_JRE_KEY.to_string(), "21.0.12".to_string());
record_local_agent_install(state, db_type, DEFAULT_JRE_KEY);
})
.unwrap();
});
}
start.wait();
});
let state = manager.load_state();
assert!(state.installed_drivers.contains_key("oracle"));
assert!(state.installed_drivers.contains_key("dameng"));
assert_eq!(state.jre_versions[DEFAULT_JRE_KEY], "21.0.12");
}
#[tokio::test]
async fn batch_registry_install_waits_for_an_existing_driver_operation() {
let manager = test_manager("batch-driver-operation-lock");
let db_type = "oracle";
let version = "0.1.31";
let native_url = "https://example.com/dbx-agent-oracle";
let native_bytes = b"native-agent";
let registry = registry_with_native_and_legacy_jar(db_type, version, native_url, native_bytes.len() as u64);
write_cached_driver_download(
&manager,
db_type,
version,
native_url,
&manager.driver_native_path(db_type),
native_bytes,
);
let first_lock = driver_operation_lock(&manager, "oracle").await;
let first_guard = first_lock.lock().await;
let progress = |_| {};
let blocked = tokio::time::timeout(std::time::Duration::from_millis(50), async {
install_agent_driver_from_registry_locked(
&manager,
&registry,
DownloadSource::Official,
db_type,
&progress,
Some(1),
Some(1),
)
.await
})
.await;
assert!(blocked.is_err(), "batch install entered while another operation owned the driver files");
drop(first_guard);
tokio::time::timeout(
std::time::Duration::from_secs(1),
install_agent_driver_from_registry_locked(
&manager,
&registry,
DownloadSource::Official,
db_type,
&progress,
Some(1),
Some(1),
),
)
.await
.expect("batch install did not resume after the driver lock was released")
.unwrap();
assert_eq!(std::fs::read(manager.driver_native_path(db_type)).unwrap(), native_bytes);
}
#[tokio::test]
async fn manual_import_waits_for_an_existing_driver_operation() {
let manager = test_manager("manual-import-driver-operation-lock");
let db_type = "h2";
let source = manager.base_dir().join("dbx-agent-h2.jar");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
write_test_agent_jar(&source);
let lock = driver_operation_lock(&manager, db_type).await;
let first_guard = lock.lock().await;
let blocked =
tokio::time::timeout(std::time::Duration::from_millis(50), import_agent_driver(&manager, db_type, &source))
.await;
assert!(blocked.is_err(), "manual import entered while another operation owned the driver files");
drop(first_guard);
tokio::time::timeout(std::time::Duration::from_secs(1), import_agent_driver(&manager, db_type, &source))
.await
.expect("manual import did not resume after the driver lock was released")
.unwrap();
assert_eq!(std::fs::read(manager.driver_jar_path(db_type)).unwrap(), std::fs::read(source).unwrap());
}
#[tokio::test]
async fn jre_exclusive_operation_waits_for_in_flight_driver_operation() {
let manager = test_manager("jre-exclusive-operation-lock");
let driver_guard = manager.installation_operation_lock.read().await;
let blocked =
tokio::time::timeout(std::time::Duration::from_millis(50), manager.installation_operation_lock.write())
.await;
assert!(blocked.is_err(), "JRE replacement entered before an in-flight driver operation completed");
drop(driver_guard);
let _jre_guard =
tokio::time::timeout(std::time::Duration::from_secs(1), manager.installation_operation_lock.write())
.await
.expect("JRE replacement did not resume after driver operations completed");
}
#[tokio::test]
async fn registry_install_rejects_corrupt_downloaded_jar() {
let manager = test_manager("corrupt-jar");
@ -1943,6 +2462,24 @@ mod agent_registry_install_tests {
assert!(!jar_path.exists());
assert!(!manager.load_state().installed_drivers.contains_key(db_type));
}
#[tokio::test]
async fn offline_import_exclusive_lock_waits_for_in_flight_driver_operation() {
let manager = test_manager("offline-import-lock");
// Simulate an in-flight driver operation holding a read lock.
let driver_guard = manager.installation_operation_lock.read().await;
// import_offline_zip acquires the write lock — it must wait.
let blocked =
tokio::time::timeout(std::time::Duration::from_millis(50), manager.installation_operation_lock.write())
.await;
assert!(blocked.is_err(), "offline import entered before an in-flight driver operation completed");
drop(driver_guard);
let _offline_guard =
tokio::time::timeout(std::time::Duration::from_secs(1), manager.installation_operation_lock.write())
.await
.expect("offline import did not resume after driver operations completed");
}
}
#[cfg(test)]

View File

@ -163,11 +163,11 @@ async fn connect_and_authenticate(
async fn try_authenticate_with_agent(
session: &mut Handle<SshClient>,
ssh_user: &str,
ssh_agent_sock_path: &str,
_ssh_agent_sock_path: &str,
connect_timeout: &Duration,
) -> Result<(), String> {
#[cfg(unix)]
let mut agent = if ssh_agent_sock_path.is_empty() {
let mut agent = if _ssh_agent_sock_path.is_empty() {
match AgentClient::connect_env().await {
Ok(a) => a,
Err(e) => {
@ -175,12 +175,12 @@ async fn try_authenticate_with_agent(
}
}
} else {
match AgentClient::connect_uds(ssh_agent_sock_path).await {
match AgentClient::connect_uds(_ssh_agent_sock_path).await {
Ok(a) => a,
Err(e) => {
return Err(format!(
"No SSH password or key provided, and ssh-agent at '{}' is unavailable: {e}",
ssh_agent_sock_path
_ssh_agent_sock_path
));
}
}

View File

@ -364,7 +364,9 @@ fn atomic_replace_moves_download_into_place() {
#[test]
fn agent_progress_event_serializes_backward_compatible_fields() {
let event = AgentProgressEvent::transfer("driver", 512, 1024).with_batch(Some("h2"), Some(1), Some(2));
let event = AgentProgressEvent::transfer("driver", 512, 1024)
.with_batch(Some("h2"), Some(1), Some(2))
.with_operation_id("upgrade-123");
let value = serde_json::to_value(event).unwrap();
@ -374,16 +376,17 @@ fn agent_progress_event_serializes_backward_compatible_fields() {
assert_eq!(value["db_type"], "h2");
assert_eq!(value["current"], 1);
assert_eq!(value["total_drivers"], 2);
assert_eq!(value["operation_id"], "upgrade-123");
}
#[test]
fn local_jar_import_updates_driver_state() {
#[tokio::test]
async fn local_jar_import_updates_driver_state() {
let manager = test_manager("local-import");
let source = test_path("local-import-source").join("dbx-agent-h2.jar");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
write_test_agent_jar(&source);
import_agent_jar(&manager, "h2", &source).unwrap();
import_agent_jar(&manager, "h2", &source).await.unwrap();
assert_eq!(std::fs::read(manager.driver_jar_path("h2")).unwrap(), std::fs::read(&source).unwrap());
let state = manager.load_state();
@ -392,22 +395,22 @@ fn local_jar_import_updates_driver_state() {
assert_eq!(installed.jre, DEFAULT_JRE_KEY);
}
#[test]
fn local_jar_import_rejects_corrupt_jar() {
#[tokio::test]
async fn local_jar_import_rejects_corrupt_jar() {
let manager = test_manager("local-import-corrupt");
let source = test_path("local-import-corrupt-source").join("dbx-agent-h2.jar");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, b"jar").unwrap();
let err = import_agent_jar(&manager, "h2", &source).unwrap_err();
let err = import_agent_jar(&manager, "h2", &source).await.unwrap_err();
assert!(err.contains("invalid or corrupt"));
assert!(!manager.driver_jar_path("h2").exists());
assert!(!manager.load_state().installed_drivers.contains_key("h2"));
}
#[test]
fn local_native_import_installs_current_platform_executable() {
#[tokio::test]
async fn local_native_import_installs_current_platform_executable() {
let manager = test_manager("local-native-import");
let source = test_path("local-native-import-source").join(if cfg!(windows) {
"dbx-agent-kingbase-windows.exe"
@ -417,35 +420,35 @@ fn local_native_import_installs_current_platform_executable() {
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, current_platform_native_binary()).unwrap();
import_agent_driver(&manager, "kingbase", &source).unwrap();
import_agent_driver(&manager, "kingbase", &source).await.unwrap();
assert_eq!(std::fs::read(manager.driver_native_path("kingbase")).unwrap(), std::fs::read(&source).unwrap());
assert!(!manager.driver_jar_path("kingbase").exists());
assert_eq!(manager.load_state().installed_drivers["kingbase"].version, "0.1.0-local");
}
#[test]
fn local_native_import_rejects_wrong_platform_binary() {
#[tokio::test]
async fn local_native_import_rejects_wrong_platform_binary() {
let manager = test_manager("local-native-import-invalid");
let source = test_path("local-native-import-invalid-source").join("dbx-agent-kingbase");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, b"not-an-executable").unwrap();
let err = import_agent_driver(&manager, "kingbase", &source).unwrap_err();
let err = import_agent_driver(&manager, "kingbase", &source).await.unwrap_err();
assert!(err.contains(AgentManager::current_platform()));
assert!(!manager.driver_native_path("kingbase").exists());
assert!(!manager.load_state().installed_drivers.contains_key("kingbase"));
}
#[test]
fn local_native_import_rejects_wrong_arch_binary() {
#[tokio::test]
async fn local_native_import_rejects_wrong_arch_binary() {
let manager = test_manager("local-native-wrong-arch");
let native_path = test_path("local-native-wrong-arch-file").join("agent");
std::fs::create_dir_all(native_path.parent().unwrap()).unwrap();
std::fs::write(&native_path, native_binary_for_arch(!cfg!(target_arch = "aarch64"))).unwrap();
let err = import_agent_driver(&manager, "kingbase", &native_path).unwrap_err();
let err = import_agent_driver(&manager, "kingbase", &native_path).await.unwrap_err();
assert!(err.contains("not a"));
assert!(!manager.driver_native_path("kingbase").exists());
@ -515,8 +518,8 @@ fn clear_download_cache_removes_only_cache_entries() {
assert!(installed_driver.exists());
}
#[test]
fn offline_zip_import_emits_progress_and_updates_state() {
#[tokio::test]
async fn offline_zip_import_emits_progress_and_updates_state() {
let manager = test_manager("offline-progress");
let zip_path = test_path("offline-progress-zip").join("agents.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
@ -526,6 +529,7 @@ fn offline_zip_import_emits_progress_and_updates_state() {
let result = import_agents_from_zip(&manager, &zip_path, |event| {
events.lock().unwrap().push(event);
})
.await
.unwrap();
assert_eq!(result.drivers_installed, vec!["h2"]);
@ -533,11 +537,12 @@ fn offline_zip_import_emits_progress_and_updates_state() {
assert!(installed_jar.windows(b"Main-Class:".len()).any(|window| window == b"Main-Class:"));
assert_eq!(manager.load_state().installed_drivers.get("h2").unwrap().version, "0.2.0");
let events = events.lock().unwrap();
assert!(events.iter().any(|event| event.step == "driver" && event.db_type.as_deref() == Some("H2")));
// db_type carries the real database key, not the display label.
assert!(events.iter().any(|event| event.step == "driver" && event.db_type.as_deref() == Some("h2")));
}
#[test]
fn offline_zip_import_installs_release_named_jre() {
#[tokio::test]
async fn offline_zip_import_installs_release_named_jre() {
let manager = test_manager("offline-release-jre");
let zip_path = test_path("offline-release-jre-zip").join("agents.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
@ -547,6 +552,7 @@ fn offline_zip_import_installs_release_named_jre() {
let result = import_agents_from_zip(&manager, &zip_path, |event| {
events.lock().unwrap().push(event);
})
.await
.unwrap();
assert_eq!(result.jre_installed, vec![DEFAULT_JRE_KEY]);
@ -556,35 +562,35 @@ fn offline_zip_import_installs_release_named_jre() {
assert!(events.lock().unwrap().iter().any(|event| event.step == "jre-extract"));
}
#[test]
fn offline_zip_import_preserves_existing_jre_when_archive_is_corrupt() {
#[tokio::test]
async fn offline_zip_import_preserves_existing_jre_when_archive_is_corrupt() {
let manager = test_manager("offline-corrupt-jre-preserves-existing");
let root = test_path("offline-corrupt-jre-preserves-existing-zip");
let valid_zip = root.join("valid.zip");
let corrupt_zip = root.join("corrupt.zip");
std::fs::create_dir_all(&root).unwrap();
write_offline_driver_zip_with_jre(&valid_zip, "h2", "0.2.0", "21.0.12");
import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap();
import_agents_from_zip(&manager, &valid_zip, |_| {}).await.unwrap();
let java_path = manager.jre_java_path(DEFAULT_JRE_KEY);
let original_java = std::fs::read(&java_path).unwrap();
write_offline_driver_zip_with_jre_bytes(&corrupt_zip, "h2", "0.3.0", "21.0.13", b"not-a-tar-gz".to_vec());
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).await.unwrap_err();
assert!(err.contains("Failed to extract JRE archive"));
assert_eq!(std::fs::read(java_path).unwrap(), original_java);
assert_eq!(manager.load_state().jre_versions.get(DEFAULT_JRE_KEY).map(String::as_str), Some("21.0.12"));
}
#[test]
fn offline_zip_import_preserves_existing_jre_when_driver_is_corrupt() {
#[tokio::test]
async fn offline_zip_import_preserves_existing_jre_when_driver_is_corrupt() {
let manager = test_manager("offline-corrupt-driver-preserves-jre");
let root = test_path("offline-corrupt-driver-preserves-jre-zip");
let valid_zip = root.join("valid.zip");
let corrupt_zip = root.join("corrupt.zip");
std::fs::create_dir_all(&root).unwrap();
write_offline_driver_zip_with_jre(&valid_zip, "h2", "0.2.0", "21.0.12");
import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap();
import_agents_from_zip(&manager, &valid_zip, |_| {}).await.unwrap();
let java_path = manager.jre_java_path(DEFAULT_JRE_KEY);
let original_java = std::fs::read(&java_path).unwrap();
@ -596,48 +602,48 @@ fn offline_zip_import_preserves_existing_jre_when_driver_is_corrupt() {
test_jre_archive_bytes(),
b"jar".to_vec(),
);
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).await.unwrap_err();
assert!(err.contains("invalid or corrupt"));
assert_eq!(std::fs::read(java_path).unwrap(), original_java);
assert_eq!(manager.load_state().jre_versions.get(DEFAULT_JRE_KEY).map(String::as_str), Some("21.0.12"));
}
#[test]
fn offline_zip_import_rejects_corrupt_jar() {
#[tokio::test]
async fn offline_zip_import_rejects_corrupt_jar() {
let manager = test_manager("offline-corrupt-driver");
let zip_path = test_path("offline-corrupt-driver-zip").join("agents.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
write_offline_driver_zip_with_jar(&zip_path, "h2", "0.2.0", b"jar".to_vec());
let err = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &zip_path, |_| {}).await.unwrap_err();
assert!(err.contains("invalid or corrupt"));
assert!(!manager.driver_jar_path("h2").exists());
assert!(!manager.load_state().installed_drivers.contains_key("h2"));
}
#[test]
fn offline_zip_import_preserves_existing_driver_when_jar_is_corrupt() {
#[tokio::test]
async fn offline_zip_import_preserves_existing_driver_when_jar_is_corrupt() {
let manager = test_manager("offline-corrupt-driver-preserves-existing");
let root = test_path("offline-corrupt-driver-preserves-existing-zip");
let valid_zip = root.join("valid.zip");
let corrupt_zip = root.join("corrupt.zip");
std::fs::create_dir_all(&root).unwrap();
write_offline_driver_zip(&valid_zip, "h2", "0.2.0");
import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap();
import_agents_from_zip(&manager, &valid_zip, |_| {}).await.unwrap();
let original = std::fs::read(manager.driver_jar_path("h2")).unwrap();
write_offline_driver_zip_with_jar(&corrupt_zip, "h2", "0.3.0", b"jar".to_vec());
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &corrupt_zip, |_| {}).await.unwrap_err();
assert!(err.contains("invalid or corrupt"));
assert_eq!(std::fs::read(manager.driver_jar_path("h2")).unwrap(), original);
assert_eq!(manager.load_state().installed_drivers["h2"].version, "0.2.0");
}
#[test]
fn offline_zip_import_keeps_legacy_unversioned_jar_compatibility() {
#[tokio::test]
async fn offline_zip_import_keeps_legacy_unversioned_jar_compatibility() {
let manager = test_manager("offline-legacy-jar-zip");
let zip_path = test_path("offline-legacy-jar-zip").join("h2.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
@ -662,19 +668,19 @@ fn offline_zip_import_keeps_legacy_unversioned_jar_compatibility() {
std::io::Write::write_all(&mut zip, &jar).unwrap();
zip.finish().unwrap();
import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap();
import_agents_from_zip(&manager, &zip_path, |_| {}).await.unwrap();
assert_eq!(manager.load_state().installed_drivers["h2"].version, "0.1.9");
}
#[test]
fn offline_zip_import_installs_versioned_native_driver_package() {
#[tokio::test]
async fn offline_zip_import_installs_versioned_native_driver_package() {
let manager = test_manager("offline-native-driver-zip");
let zip_path = test_path("offline-native-driver-zip").join("kingbase.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
write_offline_native_driver_zip(&zip_path, "kingbase", "0.1.34");
let result = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap();
let result = import_agents_from_zip(&manager, &zip_path, |_| {}).await.unwrap();
assert_eq!(result.drivers_installed, vec!["kingbase"]);
assert_eq!(manager.load_state().installed_drivers["kingbase"].version, "0.1.34");
@ -682,30 +688,30 @@ fn offline_zip_import_installs_versioned_native_driver_package() {
assert!(!manager.driver_jar_path("kingbase").exists());
}
#[test]
fn offline_zip_import_rejects_native_driver_for_another_platform() {
#[tokio::test]
async fn offline_zip_import_rejects_native_driver_for_another_platform() {
let manager = test_manager("offline-wrong-platform-native-driver-zip");
let zip_path = test_path("offline-wrong-platform-native-driver-zip").join("kingbase.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
let other_platform = if AgentManager::current_platform() == "windows-x64" { "linux-x64" } else { "windows-x64" };
write_offline_native_driver_zip_for_platform(&zip_path, "kingbase", "0.1.34", other_platform);
let err = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &zip_path, |_| {}).await.unwrap_err();
assert!(err.contains("no drivers compatible"));
assert!(err.contains(AgentManager::current_platform()));
assert!(!manager.is_driver_installed("kingbase"));
}
#[test]
fn offline_zip_import_preserves_existing_native_driver_when_binary_is_invalid() {
#[tokio::test]
async fn offline_zip_import_preserves_existing_native_driver_when_binary_is_invalid() {
let manager = test_manager("offline-invalid-native-preserves-existing");
let root = test_path("offline-invalid-native-preserves-existing-zip");
let valid_zip = root.join("valid.zip");
let invalid_zip = root.join("invalid.zip");
std::fs::create_dir_all(&root).unwrap();
write_offline_native_driver_zip(&valid_zip, "kingbase", "0.1.34");
import_agents_from_zip(&manager, &valid_zip, |_| {}).unwrap();
import_agents_from_zip(&manager, &valid_zip, |_| {}).await.unwrap();
let original = std::fs::read(manager.driver_native_path("kingbase")).unwrap();
write_offline_native_driver_zip_with_bytes(
@ -715,7 +721,7 @@ fn offline_zip_import_preserves_existing_native_driver_when_binary_is_invalid()
AgentManager::current_platform(),
b"not-a-native-agent".to_vec(),
);
let err = import_agents_from_zip(&manager, &invalid_zip, |_| {}).unwrap_err();
let err = import_agents_from_zip(&manager, &invalid_zip, |_| {}).await.unwrap_err();
assert!(err.contains("not a"));
assert_eq!(std::fs::read(manager.driver_native_path("kingbase")).unwrap(), original);
@ -763,15 +769,15 @@ fn offline_zip_import_rejects_unsafe_entry_path() {
assert!(err.contains("unsafe path"));
}
#[test]
fn offline_zip_import_prefers_native_artifact_over_java_fallback() {
#[tokio::test]
async fn offline_zip_import_prefers_native_artifact_over_java_fallback() {
let manager = test_manager("offline-native-preferred");
let zip_path = test_path("offline-native-preferred-zip").join("kingbase.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
write_offline_hybrid_driver_zip(&zip_path, "kingbase", "0.1.34");
let plan = inspect_offline_zip(&zip_path).unwrap();
let result = import_agents_from_zip(&manager, &zip_path, |_| {}).unwrap();
let result = import_agents_from_zip(&manager, &zip_path, |_| {}).await.unwrap();
assert_eq!(plan.driver_keys, vec!["kingbase"]);
assert_eq!(result.drivers_installed, vec!["kingbase"]);
@ -779,6 +785,36 @@ fn offline_zip_import_prefers_native_artifact_over_java_fallback() {
assert!(!manager.driver_jar_path("kingbase").exists());
}
#[tokio::test]
async fn offline_zip_import_progress_carries_real_db_type_not_label() {
// Verify that the AgentProgressEvent.db_type field carries the real
// database key (e.g. "kingbase") rather than the display label
// (e.g. "KingbaseES"), so the frontend per-driver progress routing
// matches.
let manager = test_manager("offline-progress-db-type");
let zip_path = test_path("offline-progress-db-type-zip").join("kingbase.zip");
std::fs::create_dir_all(zip_path.parent().unwrap()).unwrap();
write_offline_native_driver_zip(&zip_path, "kingbase", "0.1.34");
let events = std::sync::Mutex::new(Vec::new());
import_agents_from_zip(&manager, &zip_path, |event| {
events.lock().unwrap().push(event);
})
.await
.unwrap();
let events = events.lock().unwrap();
let driver_events: Vec<_> = events.iter().filter(|e| e.step == "driver").collect();
assert!(!driver_events.is_empty(), "expected at least one driver progress event");
for event in &driver_events {
assert_eq!(
event.db_type.as_deref(),
Some("kingbase"),
"db_type must be the real key 'kingbase', not the display label"
);
}
}
fn write_offline_driver_zip(path: &std::path::Path, db_type: &str, version: &str) {
write_offline_driver_zip_with_jar(path, db_type, version, test_agent_jar_bytes());
}

View File

@ -25,12 +25,20 @@ use crate::state::WebState;
#[serde(rename_all = "camelCase")]
pub struct AgentTypeRequest {
pub db_type: String,
pub operation_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JreRequest {
pub jre_key: Option<String>,
pub operation_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentOperationRequest {
pub operation_id: Option<String>,
}
#[derive(Deserialize)]
@ -101,23 +109,32 @@ pub async fn install_agent(
Json(req): Json<AgentTypeRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&req.db_type)).await.map_err(AppError::from)?;
let operation_id = req.operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let tx = progress_sender(&state, "global").await;
install_agent_driver(&state.app.agent_manager, &req.db_type, |event| send_progress_event(&tx, event))
.await
.map_err(AppError::from)?;
install_agent_driver(&state.app.agent_manager, &req.db_type, |event| {
send_progress_event(&tx, event.with_operation_id(&operation_id))
})
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::json!({ "ok": true })))
}
pub async fn upgrade_all_agents(State(state): State<Arc<WebState>>) -> Result<Json<serde_json::Value>, AppError> {
pub async fn upgrade_all_agents(
State(state): State<Arc<WebState>>,
Json(req): Json<AgentOperationRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let registry = fetch_registry().await.map_err(AppError::from)?;
let agents = build_agent_list(&state.app.agent_manager, Some(&registry));
let updatable: Vec<String> =
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
ensure_no_agent_update_blockers(&state.app, &updatable).await.map_err(AppError::from)?;
let operation_id = req.operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let tx = progress_sender(&state, "global").await;
let result = upgrade_all_agent_drivers(&state.app.agent_manager, |event| send_progress_event(&tx, event))
.await
.map_err(AppError::from)?;
let result = upgrade_all_agent_drivers(&state.app.agent_manager, |event| {
send_progress_event(&tx, event.with_operation_id(&operation_id))
})
.await
.map_err(AppError::from)?;
Ok(Json(serde_json::to_value(result).map_err(|err| AppError::from(err.to_string()))?))
}
@ -152,9 +169,7 @@ pub async fn set_agent_java_runtime_config(
config.custom_java_path = None;
}
let mut local_state = am.load_state();
local_state.java_runtime = config.clone();
am.save_state(&local_state).map_err(AppError::from)?;
am.mutate_state(|local_state| local_state.java_runtime = config.clone()).map_err(AppError::from)?;
am.stop_daemons().await;
Ok(Json(config))
}
@ -171,7 +186,19 @@ pub async fn import_agents_from_zip(
let tmp_dir = state.data_dir.join("tmp");
std::fs::create_dir_all(&tmp_dir).map_err(|err| AppError::from(err.to_string()))?;
if let Some(field) = multipart.next_field().await.map_err(|err| AppError::from(err.to_string()))? {
let mut operation_id = uuid::Uuid::new_v4().to_string();
while let Some(field) = multipart.next_field().await.map_err(|err| AppError::from(err.to_string()))? {
if field.name() == Some("operationId") {
let candidate = field.text().await.map_err(|err| AppError::from(err.to_string()))?;
if !candidate.is_empty() {
operation_id = candidate;
}
continue;
}
if field.name() != Some("file") {
continue;
}
let file_name = field.file_name().unwrap_or("offline-drivers.zip").to_string();
if !file_name.to_ascii_lowercase().ends_with(".zip") {
return Err(AppError::from("Offline driver package must be a .zip file".to_string()));
@ -190,14 +217,17 @@ pub async fn import_agents_from_zip(
let plan = inspect_offline_zip(&zip_path).map_err(AppError::from)?;
ensure_no_offline_import_blockers(&state.app, &plan).await.map_err(AppError::from)?;
import_agents_from_zip_core(&state.app.agent_manager, &zip_path, |event| send_progress_event(&tx, event))
.map_err(AppError::from)
import_agents_from_zip_core(&state.app.agent_manager, &zip_path, |event| {
send_progress_event(&tx, event.with_operation_id(&operation_id))
})
.await
.map_err(AppError::from)
}
.await;
let _ = std::fs::remove_file(&zip_path);
let result = result?;
send_progress_event(&tx, AgentProgressEvent::step("done"));
send_progress_event(&tx, AgentProgressEvent::step("done").with_operation_id(&operation_id));
return Ok(Json(serde_json::json!({ "count": result.drivers_installed.len() as u32 })));
}
@ -237,7 +267,7 @@ pub async fn import_agent_driver_file(
let result = async {
ensure_no_agent_update_blockers(&state.app, std::slice::from_ref(&db_type)).await.map_err(AppError::from)?;
import_agent_driver(&state.app.agent_manager, &db_type, &tmp_path).map_err(AppError::from)
import_agent_driver(&state.app.agent_manager, &db_type, &tmp_path).await.map_err(AppError::from)
}
.await;
let _ = std::fs::remove_file(&tmp_path);
@ -249,9 +279,10 @@ pub async fn reinstall_jre(
State(state): State<Arc<WebState>>,
Json(req): Json<JreRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let operation_id = req.operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let tx = progress_sender(&state, "global").await;
reinstall_agent_jre(&state.app.agent_manager, req.jre_key.as_deref().unwrap_or(DEFAULT_JRE_KEY), |event| {
send_progress_event(&tx, event);
send_progress_event(&tx, event.with_operation_id(&operation_id));
})
.await
.map_err(AppError::from)?;

View File

@ -17,7 +17,7 @@ test("shares the configured download source with agent driver management", () =>
assert.match(driverStore, /void forceRefresh\(\)\.catch\(\(\) => undefined\)/);
assert.match(backendApi, /backend\.listInstalledAgents\(useSettingsStore\(\)\.editorSettings\.updateDownloadSource\)/);
assert.match(backendApi, /backend\.installAgent\(dbType, useSettingsStore\(\)\.editorSettings\.updateDownloadSource\)/);
assert.match(backendApi, /backend\.upgradeAllAgents\(useSettingsStore\(\)\.editorSettings\.updateDownloadSource\)/);
assert.match(backendApi, /backend\.reinstallJre\(jreKey, useSettingsStore\(\)\.editorSettings\.updateDownloadSource\)/);
assert.match(backendApi, /backend\.installAgent\(dbType, useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
assert.match(backendApi, /backend\.upgradeAllAgents\(useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
assert.match(backendApi, /backend\.reinstallJre\(jreKey, useSettingsStore\(\)\.editorSettings\.updateDownloadSource(?:, operationId)?\)/);
});

View File

@ -16,13 +16,13 @@ test("returns null when install progress has no measurable total", () => {
});
test("targets only the row currently installing or upgrading", () => {
assert.equal(isDriverInstallProgressTarget("mysql", { installing: "mysql", upgradingAll: false, progress: null }), true);
assert.equal(isDriverInstallProgressTarget("postgres", { installing: "mysql", upgradingAll: false, progress: null }), false);
assert.equal(isDriverInstallProgressTarget("mysql", { installing: "mysql", upgradingAll: false, progressMap: {} }), true);
assert.equal(isDriverInstallProgressTarget("postgres", { installing: "mysql", upgradingAll: false, progressMap: {} }), false);
assert.equal(
isDriverInstallProgressTarget("postgres", {
installing: null,
upgradingAll: true,
progress: { step: "driver", db_type: "postgres", downloaded: 1, total: 2 },
progressMap: { postgres: { step: "driver", db_type: "postgres", downloaded: 1, total: 2 } },
}),
true,
);

View File

@ -69,11 +69,13 @@ pub async fn install_agent(
state: State<'_, Arc<AppState>>,
db_type: String,
source: Option<DownloadSource>,
operation_id: Option<String>,
) -> Result<(), String> {
ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?;
let app_handle = app.clone();
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
install_agent_driver_from(&state.agent_manager, &db_type, source.unwrap_or_default(), move |event| {
emit_agent_progress(&app_handle, event)
emit_agent_progress(&app_handle, &operation_id, event)
})
.await
}
@ -83,6 +85,7 @@ pub async fn upgrade_all_agents(
app: tauri::AppHandle,
state: State<'_, Arc<AppState>>,
source: Option<DownloadSource>,
operation_id: Option<String>,
) -> Result<UpgradeAllAgentDriversResult, String> {
let source = source.unwrap_or_default();
let registry = fetch_registry_from(source).await?;
@ -91,8 +94,11 @@ pub async fn upgrade_all_agents(
agents.iter().filter(|agent| agent.update_available).map(|agent| agent.db_type.clone()).collect();
ensure_no_agent_update_blockers(state.inner().as_ref(), &updatable).await?;
let app_handle = app.clone();
upgrade_all_agent_drivers_from(&state.agent_manager, source, move |event| emit_agent_progress(&app_handle, event))
.await
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
upgrade_all_agent_drivers_from(&state.agent_manager, source, move |event| {
emit_agent_progress(&app_handle, &operation_id, event)
})
.await
}
#[tauri::command]
@ -136,9 +142,7 @@ pub async fn set_agent_java_runtime_config(
config.custom_java_path = None;
}
let mut local_state = am.load_state();
local_state.java_runtime = config.clone();
am.save_state(&local_state)?;
am.mutate_state(|local_state| local_state.java_runtime = config.clone())?;
am.stop_daemons().await;
Ok(config)
}
@ -159,15 +163,19 @@ pub async fn import_agents_from_zip(
app: tauri::AppHandle,
state: State<'_, Arc<AppState>>,
path: String,
operation_id: Option<String>,
) -> Result<u32, String> {
let am = &state.agent_manager;
let zip_path = std::path::PathBuf::from(&path);
let plan = inspect_offline_zip(&zip_path)?;
ensure_no_offline_import_blockers(state.inner().as_ref(), &plan).await?;
let app_handle = app.clone();
let result = import_agents_from_zip_core(am, &zip_path, |event| emit_agent_progress(&app_handle, event))?;
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let result =
import_agents_from_zip_core(am, &zip_path, |event| emit_agent_progress(&app_handle, &operation_id, event))
.await?;
let count = result.drivers_installed.len() as u32;
emit_agent_progress(&app, AgentProgressEvent::step("done"));
emit_agent_progress(&app, &operation_id, AgentProgressEvent::step("done"));
Ok(count)
}
@ -178,7 +186,7 @@ pub async fn import_agent_driver_cmd(
path: String,
) -> Result<(), String> {
ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?;
import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path))
import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path)).await
}
#[tauri::command]
@ -188,7 +196,7 @@ pub async fn import_agent_jar_cmd(
path: String,
) -> Result<(), String> {
ensure_no_agent_update_blockers(state.inner().as_ref(), std::slice::from_ref(&db_type)).await?;
import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path))
import_agent_driver(&state.agent_manager, &db_type, std::path::Path::new(&path)).await
}
#[tauri::command]
@ -197,17 +205,19 @@ pub async fn reinstall_jre(
state: State<'_, Arc<AppState>>,
jre_key: Option<String>,
source: Option<DownloadSource>,
operation_id: Option<String>,
) -> Result<(), String> {
let key = jre_key.as_deref().unwrap_or(DEFAULT_JRE_KEY);
let app_handle = app.clone();
let operation_id = operation_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
reinstall_agent_jre_from(&state.agent_manager, key, source.unwrap_or_default(), move |event| {
emit_agent_progress(&app_handle, event)
emit_agent_progress(&app_handle, &operation_id, event)
})
.await
}
fn emit_agent_progress(app: &tauri::AppHandle, event: AgentProgressEvent) {
let _ = app.emit("agent-install-progress", event);
fn emit_agent_progress(app: &tauri::AppHandle, operation_id: &str, event: AgentProgressEvent) {
let _ = app.emit("agent-install-progress", event.with_operation_id(operation_id));
}
async fn ensure_no_agent_update_blockers(state: &AppState, db_types: &[String]) -> Result<(), String> {