feat: parallel agent upgrades with per-host log panels (0.4.2)

Allow multiple remote update jobs at once; each upgrade gets its own
bottom-right log dock that auto-closes 30s after success.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-06-25 10:22:31 +10:00
parent 719d5c5719
commit dd2ae51b43
10 changed files with 267 additions and 174 deletions
+151 -63
View File
@@ -12,60 +12,94 @@ import { emitHostPatch } from "../utils/hostPatchBus";
export type HostRemoteActionKind = "fallback" | "ssh-update";
export const hostRemoteActionLog = reactive({
open: false,
title: "",
loading: false,
ok: null as boolean | null,
message: "",
output: "",
hostId: 0,
});
export type HostRemoteActionLogEntry = {
id: string;
hostId: number;
open: boolean;
loading: boolean;
ok: boolean | null;
title: string;
message: string;
output: string;
};
export const hostRemoteActionLogs = reactive<HostRemoteActionLogEntry[]>([]);
const POLL_MS = 1500;
const SUCCESS_AUTO_CLOSE_MS = 30_000;
let successAutoCloseTimer: ReturnType<typeof setTimeout> | null = null;
let nextLogSeq = 1;
const autoCloseTimers = new Map<string, ReturnType<typeof setTimeout>>();
const hostRunningPromises = new Map<number, Promise<HostRemoteActionJobStatus | null>>();
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function clearSuccessAutoCloseTimer() {
if (successAutoCloseTimer != null) {
clearTimeout(successAutoCloseTimer);
successAutoCloseTimer = null;
function newLogId(): string {
return `ra-${Date.now()}-${nextLogSeq++}`;
}
function findLogById(entryId: string): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.id === entryId);
}
function findLoadingLogForHost(hostId: number): HostRemoteActionLogEntry | undefined {
return hostRemoteActionLogs.find((e) => e.hostId === hostId && e.loading);
}
function clearAutoCloseTimer(entryId: string) {
const timer = autoCloseTimers.get(entryId);
if (timer != null) {
clearTimeout(timer);
autoCloseTimers.delete(entryId);
}
}
function scheduleSuccessAutoClose() {
clearSuccessAutoCloseTimer();
successAutoCloseTimer = setTimeout(() => {
hostRemoteActionLog.open = false;
successAutoCloseTimer = null;
}, SUCCESS_AUTO_CLOSE_MS);
}
function applyJobToModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.title = job.title || hostRemoteActionLog.title;
hostRemoteActionLog.message = job.message || hostRemoteActionLog.message;
hostRemoteActionLog.output = job.output || job.stdout || "";
}
function finishModal(job: HostRemoteActionJobStatus) {
hostRemoteActionLog.loading = false;
hostRemoteActionLog.ok = job.ok ?? job.status === "success";
applyJobToModal(job);
hostRemoteActionLog.open = true;
if (hostRemoteActionLog.ok) {
scheduleSuccessAutoClose();
function removeClosedFinishedLog(entryId: string) {
const idx = hostRemoteActionLogs.findIndex((e) => e.id === entryId);
if (idx < 0) return;
const entry = hostRemoteActionLogs[idx];
if (!entry.loading && !entry.open) {
hostRemoteActionLogs.splice(idx, 1);
}
}
async function pollRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
function scheduleSuccessAutoClose(entryId: string) {
clearAutoCloseTimer(entryId);
autoCloseTimers.set(
entryId,
setTimeout(() => {
const entry = findLogById(entryId);
if (entry) {
entry.open = false;
removeClosedFinishedLog(entryId);
}
autoCloseTimers.delete(entryId);
}, SUCCESS_AUTO_CLOSE_MS),
);
}
function applyJobToEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.title = job.title || entry.title;
entry.message = job.message || entry.message;
entry.output = job.output || job.stdout || "";
}
function finishEntry(entry: HostRemoteActionLogEntry, job: HostRemoteActionJobStatus) {
entry.loading = false;
entry.ok = job.ok ?? job.status === "success";
applyJobToEntry(entry, job);
entry.open = true;
if (entry.ok) {
scheduleSuccessAutoClose(entry.id);
}
}
async function pollRemoteJob(hostId: number, entry: HostRemoteActionLogEntry): Promise<HostRemoteActionJobStatus> {
for (;;) {
const job = await fetchHostRemoteJob(hostId);
applyJobToModal(job);
applyJobToEntry(entry, job);
if (!job.active && job.status !== "running") {
return job;
}
@@ -99,20 +133,12 @@ async function startRemoteAction(hostId: number, kind: HostRemoteActionKind) {
await updateHostAgentViaSsh(hostId);
}
export async function runHostRemoteAction(
async function executeRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
entry: HostRemoteActionLogEntry,
): Promise<HostRemoteActionJobStatus | null> {
clearSuccessAutoCloseTimer();
hostRemoteActionLog.open = true;
hostRemoteActionLog.loading = true;
hostRemoteActionLog.ok = null;
hostRemoteActionLog.title = title;
hostRemoteActionLog.message = "Запуск на сервере SAC…";
hostRemoteActionLog.output = "";
hostRemoteActionLog.hostId = hostId;
try {
try {
await startRemoteAction(hostId, kind);
@@ -123,31 +149,93 @@ export async function runHostRemoteAction(
throw e;
}
}
hostRemoteActionLog.message =
"Выполняется на удалённом хосте… Можно перейти в другой раздел SAC.";
const job = await pollRemoteJob(hostId);
finishModal(job);
entry.message = "Выполняется на удалённом хосте… Можно запустить обновление других хостов.";
const job = await pollRemoteJob(hostId, entry);
finishEntry(entry, job);
await patchHostFromJob(hostId, job);
return job;
} catch (e) {
hostRemoteActionLog.loading = false;
hostRemoteActionLog.ok = false;
hostRemoteActionLog.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.loading = false;
entry.ok = false;
entry.message = e instanceof Error ? e.message : "Ошибка выполнения";
entry.open = true;
return null;
}
}
export function closeHostRemoteActionLog() {
clearSuccessAutoCloseTimer();
hostRemoteActionLog.open = false;
}
export async function runHostRemoteAction(
hostId: number,
title: string,
kind: HostRemoteActionKind,
): Promise<HostRemoteActionJobStatus | null> {
const existingPromise = hostRunningPromises.get(hostId);
if (existingPromise) {
const existing = findLoadingLogForHost(hostId);
if (existing) {
existing.open = true;
}
return existingPromise;
}
export function showHostRemoteActionLog() {
if (hostRemoteActionLog.loading || hostRemoteActionLog.ok != null) {
hostRemoteActionLog.open = true;
const entry: HostRemoteActionLogEntry = {
id: newLogId(),
hostId,
open: true,
loading: true,
ok: null,
title,
message: "Запуск на сервере SAC…",
output: "",
};
hostRemoteActionLogs.push(entry);
const promise = executeRemoteAction(hostId, title, kind, entry);
hostRunningPromises.set(hostId, promise);
try {
return await promise;
} finally {
hostRunningPromises.delete(hostId);
}
}
export function hasActiveHostRemoteAction(): boolean {
return hostRemoteActionLog.loading;
export function closeHostRemoteActionLog(entryId: string) {
clearAutoCloseTimer(entryId);
const entry = findLogById(entryId);
if (!entry) return;
entry.open = false;
if (!entry.loading) {
removeClosedFinishedLog(entryId);
}
}
export function showHostRemoteActionLog(hostId?: number) {
if (hostId != null) {
for (const entry of hostRemoteActionLogs) {
if (entry.hostId === hostId && (entry.loading || entry.ok != null)) {
entry.open = true;
}
}
return;
}
for (const entry of hostRemoteActionLogs) {
if (entry.loading || entry.ok === false) {
entry.open = true;
}
}
}
export function isHostRemoteActionActive(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.loading);
}
export function hasActiveHostRemoteAction(): boolean {
return hostRemoteActionLogs.some((e) => e.loading);
}
export function hasOpenRemoteActionLogForHost(hostId: number): boolean {
return hostRemoteActionLogs.some((e) => e.hostId === hostId && e.open);
}
export function anyRemoteActionLogOpen(): boolean {
return hostRemoteActionLogs.some((e) => e.open);
}