fix: strengthen live recovery and support diagnostics

Apply account and key changes to active queues without a restart and isolate provider attempt cancellation so fallback accounts remain usable. Preserve pause ownership, bound persisted HTTP 416 recovery, reconcile resets with authoritative state, and stabilize package ordering and live update cadence. Correlate rotation, conversion, resume, disk, queue-control, clipboard, and support-export events while redacting sensitive data at every persistent boundary and again in generated bundles. Release as v2.0.31 with updated English documentation and regression coverage.
This commit is contained in:
Sucukdeluxe
2026-08-13 11:36:51 +02:00
parent 88399c5dd0
commit 25ebc55f4f
44 changed files with 5223 additions and 959 deletions
+369 -34
View File
@@ -7,17 +7,25 @@ import { getAccountRotationLogPath } from "./account-rotation-log";
import { getConversionLogPath } from "./conversion-trace";
import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger";
import { flushLogger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log";
import { getSessionLogPath } from "./session-log";
import { flushSessionLog, getSessionLogPath } from "./session-log";
import { createStoragePaths, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload } from "./support-data";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { flushTraceLog, getTraceConfig, getTraceConfigPath, getTraceLogPath } from "./trace-log";
import { flushPackageLogs, getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { flushItemLogs, getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getCachedWindowsHostDiagnostics, getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
import {
getMegaDebridAccountsForMode,
getMegaDebridDisabledAccountIdsForMode,
maskMegaDebridLogin,
type MegaDebridAccountMode
} from "../shared/mega-debrid-accounts";
import { getProviderRuntimeSnapshot, type ProviderRuntimeCooldown, type ProviderRuntimeSnapshot } from "./debrid";
import type { DownloadManager } from "./download-manager";
import type { DownloadItem, HistoryEntry, PackageEntry, SessionState } from "../shared/types";
@@ -104,13 +112,14 @@ function redactSupportText(value: string, sensitiveValues: ReadonlySet<string>):
return String.fromCharCode(0xf8ff - offset);
};
const urlMarker = findMarker(raw, 0);
let output = raw.replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, urlMarker);
let output = raw.replace(/\b(?:https?|file):(?:\\?\/){2}[^\s"'<>]+/gi, urlMarker);
const pathMarker = findMarker(output, 1);
output = output.replace(/\b[A-Z]:[\\/][^\r\n|"<>]+/gi, pathMarker);
output = output.replace(/\\\\[^\r\n|"<>]+/g, pathMarker);
output = output.replace(/\/(?:home|Users|var|tmp)\/[^\r\n|"<>]+/g, pathMarker);
output = output.replace(/\b(?:authorization|proxy-authorization)\s*[:=]\s*[^\r\n]+/gi, "Authorization: <redacted>");
output = output.replace(/\b(?:set-cookie|cookie)\s*[:=]\s*[^\r\n]+/gi, "Cookie: <redacted>");
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*,\s*[^)\r\n]+(?=\))/gi, "$1, <redacted-account>");
output = output.replace(/\b((?:Account|Key)\s+\d+(?:\/\d+)?)\s*\([^)\r\n]*\)/gi, "$1 (<redacted-account>)");
output = output.replace(/(["']?(?:authorization|proxy-authorization|set-cookie|cookie|password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)["']?\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/gi, "$1\"<redacted>\"");
output = output.replace(/\b(password|passwd|pwd|token|access[_-]?token|refresh[_-]?token|api[_ -]?key|secret|client[_-]?secret|auth|login|username|user)\b(\s*[:=]\s*)[^\s,;|]+/gi, "$1$2<redacted>");
@@ -151,10 +160,20 @@ function redactSupportValue(value: unknown, sensitiveValues: ReadonlySet<string>
return value;
}
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>): string {
return redactSupportText(zipPath, sensitiveValues)
.split("/")
.map((part) => part.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry")
function sanitizeArchivePath(zipPath: string, sensitiveValues: ReadonlySet<string>, redactFileName: boolean): string {
const parts = zipPath.split("/");
return parts
.map((part, index) => {
const redacted = redactFileName && index === parts.length - 1
? redactSupportText(part, sensitiveValues)
: part;
const extension = path.posix.extname(redacted);
const name = extension ? redacted.slice(0, -extension.length) : redacted;
const safeName = name.replace(/[<>:"\\|?*\x00-\x1f]/g, "_").replace(/\.+$/g, "_") || "entry";
const safeExtension = extension.replace(/[<>:"\\|?*\x00-\x1f]/g, "_");
const uniqueness = redacted === part ? "" : `-${randomUUID().slice(0, 12)}`;
return `${safeName}${uniqueness}${safeExtension}`;
})
.join("/");
}
@@ -211,7 +230,8 @@ async function addTextFileIfExists(
sensitiveValues: ReadonlySet<string>,
budget: TextBudget,
maxFileBytes: number,
maxAgeMs?: number
maxAgeMs?: number,
redactArchiveFileName = false
): Promise<boolean> {
if (!sourcePath || budget.remainingBytes <= 0) {
return false;
@@ -231,7 +251,7 @@ async function addTextFileIfExists(
buffer = Buffer.from(buffer.subarray(buffer.length - allowedBytes).toString("utf8"), "utf8");
}
await yieldToEventLoop();
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues), buffer);
zip.addFile(sanitizeArchivePath(zipPath, sensitiveValues, redactArchiveFileName), buffer);
includedSourcePaths.add(sourcePathKey);
budget.remainingBytes = Math.max(0, budget.remainingBytes - buffer.length);
return true;
@@ -250,6 +270,9 @@ async function addRecentDirectoryFiles(
sensitiveValues: ReadonlySet<string>,
budget: TextBudget
): Promise<number> {
if (maxFiles <= 0 || budget.remainingBytes <= 0) {
return 0;
}
const candidates: Array<{ name: string; fullPath: string; mtimeMs: number }> = [];
let directory;
try {
@@ -292,7 +315,45 @@ async function addRecentDirectoryFiles(
includedSourcePaths,
sensitiveValues,
budget,
MAX_TEXT_FILE_BYTES
MAX_TEXT_FILE_BYTES,
undefined,
true
)) {
added += 1;
}
}
return added;
}
async function addRelevantLogFiles<T extends { id: string }>(
zip: AdmZip,
entries: readonly T[],
resolveSourcePath: (id: string) => string | null,
zipRoot: string,
maxFiles: number,
includedSourcePaths: Set<string>,
sensitiveValues: ReadonlySet<string>,
budget: TextBudget
): Promise<number> {
let added = 0;
for (const entry of entries.slice(0, maxFiles)) {
if (budget.remainingBytes <= 0) {
break;
}
const sourcePath = resolveSourcePath(entry.id);
if (!sourcePath) {
continue;
}
if (await addTextFileIfExists(
zip,
sourcePath,
path.posix.join(zipRoot, path.basename(sourcePath)),
includedSourcePaths,
sensitiveValues,
budget,
MAX_TEXT_FILE_BYTES,
undefined,
true
)) {
added += 1;
}
@@ -304,12 +365,38 @@ function isActiveStatus(status: unknown): boolean {
return !new Set(["completed", "failed", "cancelled", "extracted", "deleted"]).has(String(status || ""));
}
function createPackageDto(entry: PackageEntry): Record<string, unknown> {
function getBundleAliasExtension(value: string): string {
const extension = path.extname(String(value || "")).toLowerCase();
return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : "";
}
function createBundleAlias(prefix: "package" | "item" | "history", index: number, sourceName = ""): string {
const extension = getBundleAliasExtension(sourceName);
return `${prefix}-${String(index + 1).padStart(3, "0")}${extension}`;
}
function createPackageDto(
entry: PackageEntry,
name: string,
items: Readonly<Record<string, DownloadItem>>
): Record<string, unknown> {
const currentItems = entry.itemIds.map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
const downloadedBytes = Math.max(0, Number(entry.cleanedDownloadedBytes || 0))
+ currentItems.reduce((sum, item) => sum + Math.max(0, Number(item.downloadedBytes || 0)), 0);
const currentKnownTotals = currentItems
.map((item) => item.totalBytes)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0);
const hasKnownTotal = typeof entry.cleanedTotalBytes === "number" || currentKnownTotals.length > 0;
const totalBytes = hasKnownTotal
? Math.max(0, Number(entry.cleanedTotalBytes || 0)) + currentKnownTotals.reduce((sum, value) => sum + value, 0)
: null;
return {
id: entry.id,
name: entry.name,
name,
status: entry.status,
itemCount: entry.itemIds.length,
downloadedBytes,
totalBytes,
cancelled: entry.cancelled,
enabled: entry.enabled,
priority: entry.priority,
@@ -331,7 +418,7 @@ function getSourceHost(value: string): string {
}
}
function createItemDto(entry: DownloadItem): Record<string, unknown> {
function createItemDto(entry: DownloadItem, fileName: string): Record<string, unknown> {
return {
id: entry.id,
packageId: entry.packageId,
@@ -345,12 +432,16 @@ function createItemDto(entry: DownloadItem): Record<string, unknown> {
downloadedBytes: entry.downloadedBytes,
totalBytes: entry.totalBytes,
progressPercent: entry.progressPercent,
fileName: entry.fileName,
fileName,
targetPath: entry.targetPath ? "<local-path>" : "",
resumable: entry.resumable,
attempts: entry.attempts,
lastError: entry.lastError,
fullStatus: entry.fullStatus,
resumeLinkRenewalFailures: entry.resumeLinkRenewalFailures,
resumeHardResetUsed: entry.resumeHardResetUsed,
resumeResetPending: entry.resumeResetPending,
http416FreshRestarts: entry.http416FreshRestarts,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
onlineStatus: entry.onlineStatus
@@ -377,10 +468,10 @@ function createSessionDto(session: SessionState): Record<string, unknown> {
};
}
function createHistoryDto(entry: HistoryEntry): Record<string, unknown> {
function createHistoryDto(entry: HistoryEntry, name: string): Record<string, unknown> {
return {
id: entry.id,
name: entry.name,
name,
status: entry.status,
provider: entry.provider,
fileCount: entry.fileCount,
@@ -403,7 +494,11 @@ async function loadBoundedHistory(filePath: string): Promise<{ total: number | n
if (!Array.isArray(parsed)) {
return { total: 0, entries: [], omitted: 0 };
}
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES).map((entry) => createHistoryDto(entry as HistoryEntry));
const entries = parsed.slice(0, MAX_HISTORY_ENTRIES)
.map((entry, index) => createHistoryDto(
entry as HistoryEntry,
createBundleAlias("history", index, String((entry as HistoryEntry).name || ""))
));
return { total: parsed.length, entries, omitted: Math.max(0, parsed.length - entries.length) };
} catch {
return { total: 0, entries: [], omitted: 0 };
@@ -436,20 +531,64 @@ interface SupportBundleExportSuccess {
bytes: number;
}
export type SupportBundleExportPhase = "busy" | "cancel" | "build" | "write" | "success" | "failure";
export interface SupportBundleExportLifecycleEvent {
phase: SupportBundleExportPhase;
durationMs: number;
totalDurationMs: number;
bytes?: number;
failedPhase?: "choose" | "build" | "write";
code?: string;
}
export class SupportBundleExportError extends Error {
public readonly phase: "choose" | "build" | "write";
public readonly durationMs: number;
public readonly code?: string;
public constructor(phase: "choose" | "build" | "write", durationMs: number, code?: string) {
super(`Support-Bundle-Export fehlgeschlagen (${phase}${code ? `, ${code}` : ""}).`);
this.name = "SupportBundleExportError";
this.phase = phase;
this.durationMs = durationMs;
this.code = code;
}
}
interface SupportBundleExportRunnerOptions {
chooseFile: () => Promise<string | null>;
build: () => Promise<Buffer>;
write: (filePath: string, buffer: Buffer) => Promise<void>;
now?: () => number;
onStart?: (result: { filePath: string }) => Promise<void> | void;
onSuccess?: (result: SupportBundleExportSuccess) => Promise<void> | void;
onFailure?: (error: unknown) => Promise<void> | void;
onFailure?: (error: SupportBundleExportError) => Promise<void> | void;
onLifecycle?: (event: SupportBundleExportLifecycleEvent) => Promise<void> | void;
}
function getExportErrorCode(error: unknown): string | undefined {
const code = String((error as NodeJS.ErrnoException | null)?.code || "").trim().toUpperCase();
return /^[A-Z0-9_]{1,32}$/.test(code) ? code : undefined;
}
export function createSupportBundleExportRunner(
options: SupportBundleExportRunnerOptions
): () => Promise<SupportBundleExportResult> {
let active = false;
const now = options.now || Date.now;
const emitLifecycle = async (event: SupportBundleExportLifecycleEvent): Promise<void> => {
if (!options.onLifecycle) {
return;
}
try {
await options.onLifecycle(event);
} catch {
}
};
return async () => {
if (active) {
await emitLifecycle({ phase: "busy", durationMs: 0, totalDurationMs: 0 });
return {
saved: false,
busy: true,
@@ -457,28 +596,77 @@ export function createSupportBundleExportRunner(
};
}
active = true;
const startedAt = now();
let phase: "choose" | "build" | "write" = "choose";
let phaseStartedAt = startedAt;
try {
const filePath = await options.chooseFile();
if (!filePath) {
const finishedAt = now();
await emitLifecycle({
phase: "cancel",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt)
});
return { saved: false, busy: false };
}
if (options.onStart) {
try {
await options.onStart({ filePath });
} catch {
}
}
phase = "build";
phaseStartedAt = now();
const buffer = await options.build();
let finishedAt = now();
await emitLifecycle({
phase: "build",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
phase = "write";
phaseStartedAt = now();
await options.write(filePath, buffer);
finishedAt = now();
await emitLifecycle({
phase: "write",
durationMs: Math.max(0, finishedAt - phaseStartedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
if (options.onSuccess) {
try {
await options.onSuccess({ filePath, bytes: buffer.length });
} catch {
}
}
await emitLifecycle({
phase: "success",
durationMs: Math.max(0, finishedAt - startedAt),
totalDurationMs: Math.max(0, finishedAt - startedAt),
bytes: buffer.length
});
return { saved: true, busy: false, filePath };
} catch (error) {
const failedAt = now();
const code = getExportErrorCode(error);
const safeError = new SupportBundleExportError(phase, Math.max(0, failedAt - startedAt), code);
await emitLifecycle({
phase: "failure",
failedPhase: phase,
durationMs: Math.max(0, failedAt - phaseStartedAt),
totalDurationMs: Math.max(0, failedAt - startedAt),
...(code ? { code } : {})
});
if (options.onFailure) {
try {
await options.onFailure(error);
await options.onFailure(safeError);
} catch {
}
}
throw error;
throw safeError;
} finally {
active = false;
}
@@ -531,7 +719,7 @@ function createDeferredHostDiagnostics(reason: string): unknown {
};
}
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
if (mode === "none") {
return createDeferredHostDiagnostics("Host-Diagnose wurde fuer diesen Bundle-Export deaktiviert.");
}
@@ -542,30 +730,139 @@ function resolveHostDiagnostics(mode: HostDiagnosticsMode): unknown {
}
return createDeferredHostDiagnostics("Host-Diagnose wurde uebersprungen, um den Export nicht zu blockieren. Fuer eine Voll-Diagnose /host/diagnostics nutzen.");
}
return getWindowsHostDiagnostics();
}
return getWindowsHostDiagnostics();
}
function createCooldownDto(cooldown: ProviderRuntimeCooldown | null): Record<string, unknown> | null {
if (!cooldown) {
return null;
}
return {
category: cooldown.category,
remainingMs: Math.max(0, cooldown.remainingMs),
untilRestart: cooldown.untilRestart === true
};
}
function createMegaDebridPoolRuntime(
settings: ReturnType<typeof loadSettings>,
runtime: ProviderRuntimeSnapshot,
mode: MegaDebridAccountMode
): Record<string, unknown> {
const accounts = getMegaDebridAccountsForMode(settings, mode);
const disabledIds = new Set(getMegaDebridDisabledAccountIdsForMode(settings, mode));
const enabled = mode === "api" ? settings.megaDebridApiEnabled : settings.megaDebridWebEnabled;
const runtimeByKey = new Map(runtime.megaDebrid.accounts.map((entry) => [entry.key, entry]));
const runtimeAccounts = accounts.flatMap((account, index) => {
const state = runtimeByKey.get(`${account.id}:${mode}`);
if (!state || (!state.cooldown && state.inFlight <= 0 && state.emptyResponseStreak <= 0)) {
return [];
}
return [{
account: `Account ${index + 1}/${accounts.length}`,
inFlight: state.inFlight,
emptyResponseStreak: state.emptyResponseStreak,
cooldown: createCooldownDto(state.cooldown)
}];
});
const configuredKeys = new Set(accounts.map((account) => `${account.id}:${mode}`));
return {
enabled,
configuredCount: accounts.length,
activeCount: enabled ? accounts.filter((account) => !disabledIds.has(account.id)).length : 0,
disabledCount: accounts.filter((account) => disabledIds.has(account.id)).length,
inFlight: accounts.reduce((sum, account) => sum + (runtimeByKey.get(`${account.id}:${mode}`)?.inFlight || 0), 0),
accounts: runtimeAccounts,
unmappedRuntimeEntryCount: runtime.megaDebrid.accounts
.filter((entry) => entry.key.endsWith(`:${mode}`) && !configuredKeys.has(entry.key)).length
};
}
function createProviderRuntimeDto(settings: ReturnType<typeof loadSettings>): Record<string, unknown> {
const runtime = getProviderRuntimeSnapshot();
const debridKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
const disabledDebridKeys = new Set(settings.debridLinkDisabledKeyIds || []);
const debridRuntimeById = new Map(runtime.debridLink.keys.map((entry) => [entry.keyId, entry]));
const configuredDebridIds = new Set(debridKeys.map((entry) => entry.id));
const debridRuntimeKeys = debridKeys.flatMap((entry, index) => {
const state = debridRuntimeById.get(entry.id);
if (!state || (!state.cooldown && !state.runtimeStatus)) {
return [];
}
return [{
account: `Key ${index + 1}/${debridKeys.length}`,
cooldown: createCooldownDto(state.cooldown),
runtimeState: state.runtimeStatus?.state || null,
runtimeUpdatedAt: state.runtimeStatus?.updatedAt || null
}];
});
const hostCooldowns = runtime.debridLink.hostCooldowns.flatMap((entry) => {
const separator = entry.key.indexOf("|");
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
const host = separator >= 0 ? entry.key.slice(separator + 1) : "";
const index = debridKeys.findIndex((candidate) => candidate.id === keyId);
if (index < 0) {
return [];
}
return [{
account: `Key ${index + 1}/${debridKeys.length}`,
host,
cooldown: createCooldownDto(entry.cooldown)
}];
});
return {
capturedAtMs: runtime.capturedAtMs,
megaDebrid: {
rotationCursor: runtime.megaDebrid.rotationCursor,
stickyCount: runtime.megaDebrid.stickyCount,
pools: {
api: createMegaDebridPoolRuntime(settings, runtime, "api"),
web: createMegaDebridPoolRuntime(settings, runtime, "web")
}
},
debridLink: {
configuredCount: debridKeys.length,
activeCount: debridKeys.filter((entry) => !disabledDebridKeys.has(entry.id)).length,
disabledCount: debridKeys.filter((entry) => disabledDebridKeys.has(entry.id)).length,
keys: debridRuntimeKeys,
hostCooldowns,
unmappedRuntimeEntryCount: runtime.debridLink.keys.filter((entry) => !configuredDebridIds.has(entry.keyId)).length,
unmappedHostCooldownCount: runtime.debridLink.hostCooldowns.filter((entry) => {
const separator = entry.key.indexOf("|");
const keyId = separator >= 0 ? entry.key.slice(0, separator) : entry.key;
return !configuredDebridIds.has(keyId);
}).length
}
};
}
export async function buildSupportBundle(manager: DownloadManager, baseDir: string, options: BuildSupportBundleOptions = {}): Promise<Buffer> {
const zip = new AdmZip();
const includedSourcePaths = new Set<string>();
const textBudget: TextBudget = { remainingBytes: MAX_TOTAL_TEXT_BYTES };
const hostDiagnosticsMode = options.hostDiagnosticsMode || "full";
const debugSetupMode = options.debugSetupMode || "full";
const generatedAt = new Date().toISOString();
const storagePaths = createStoragePaths(baseDir);
const settings = loadSettings(storagePaths);
const sensitiveValues = collectSensitiveValues(settings);
const snapshot = manager.getSnapshot();
const packageEntries = Object.values(snapshot.session.packages);
const itemEntries = Object.values(snapshot.session.items);
const selectedPackages = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS).map(createPackageDto);
const selectedItems = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS).map(createItemDto);
const selectedPackageEntries = selectRelevantEntries(packageEntries, MAX_PACKAGE_DTOS);
const selectedItemEntries = selectRelevantEntries(itemEntries, MAX_ITEM_DTOS);
const selectedPackages = selectedPackageEntries
.map((entry, index) => createPackageDto(entry, createBundleAlias("package", index, entry.name), snapshot.session.items));
const selectedItems = selectedItemEntries
.map((entry, index) => createItemDto(entry, createBundleAlias("item", index, entry.fileName)));
const history = await loadBoundedHistory(storagePaths.historyFile);
const debugSetup = options.debugSetupMode === "deferred"
const debugSetup = debugSetupMode === "deferred"
? { status: "deferred", generatedAt: new Date().toISOString(), reason: "Tiefer Setup-Scan wurde beim interaktiven Export ausgelassen." }
: getDebugSetupCheck(baseDir);
await addJson(zip, "overview/meta.json", {
appVersion: APP_VERSION,
generatedAt: new Date().toISOString(),
generatedAt,
runtimeBaseDir: "<local-path>",
packageCount: packageEntries.length,
itemCount: itemEntries.length,
@@ -574,7 +871,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
itemDtos: MAX_ITEM_DTOS,
textBytes: MAX_TOTAL_TEXT_BYTES,
textFileBytes: MAX_TEXT_FILE_BYTES,
logWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000
directoryLogDiscoveryWindowHours: SUPPORT_BUNDLE_LOG_WINDOW_MS / 60 / 60 / 1000,
currentAndRelevantLogsIgnoreAgeFilter: true
}
}, sensitiveValues);
await addJson(zip, "overview/status.json", createSessionDto(snapshot.session), sensitiveValues);
@@ -589,7 +887,6 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
}
}, sensitiveValues);
await addJson(zip, "overview/debug-setup.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/self-check.json", debugSetup, sensitiveValues);
await addJson(zip, "overview/history.json", history, sensitiveValues);
await addJson(zip, "overview/packages.json", {
count: packageEntries.length,
@@ -603,6 +900,17 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
omitted: Math.max(0, itemEntries.length - selectedItems.length),
items: selectedItems
}, sensitiveValues);
await addJson(zip, "overview/runtime-diagnostics.json", {
bundleBuild: {
state: "building",
startedAt: generatedAt,
hostDiagnosticsMode,
debugSetupMode
},
rotationEvents: (snapshot.rotationEvents || []).slice(0, 60),
diskWaitEvents: (snapshot.diskWaitEvents || []).slice(-60),
providerRuntime: createProviderRuntimeDto(settings)
}, sensitiveValues);
await addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode), sensitiveValues);
await addJson(zip, "overview/trace-config.json", getTraceConfig(), sensitiveValues);
const recentErrors = getRecentErrors().slice(-100);
@@ -642,6 +950,33 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
await addRuntimeFile(path.join(baseDir, "debug_port.txt"), "runtime/debug_port.txt");
await addRuntimeFile(getTraceConfigPath(), "runtime/trace_config.json");
await flushLogger();
flushSessionLog();
flushPackageLogs();
flushItemLogs();
flushTraceLog();
const relevantPackageLogCount = await addRelevantLogFiles(
zip,
selectedPackageEntries,
getPersistedPackageLogPath,
"logs/package-logs",
MAX_PACKAGE_LOG_FILES,
includedSourcePaths,
sensitiveValues,
textBudget
);
const relevantItemLogCount = await addRelevantLogFiles(
zip,
selectedItemEntries,
getPersistedItemLogPath,
"logs/item-logs",
MAX_ITEM_LOG_FILES,
includedSourcePaths,
sensitiveValues,
textBudget
);
const mainLogPath = getLogFilePath();
const auditLogPath = getAuditLogPath();
const renameLogPath = getRenameLogPath();
@@ -664,8 +999,8 @@ export async function buildSupportBundle(manager: DownloadManager, baseDir: stri
await addRotatedLog(conversionLogPath ? `${conversionLogPath}.old` : null, "logs/conversion.log.old");
await addRecentDirectoryFiles(zip, path.join(baseDir, "session-logs"), "logs/session-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_SESSION_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_PACKAGE_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, MAX_ITEM_LOG_FILES, includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_PACKAGE_LOG_FILES - relevantPackageLogCount), includedSourcePaths, sensitiveValues, textBudget);
await addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS, Math.max(0, MAX_ITEM_LOG_FILES - relevantItemLogCount), includedSourcePaths, sensitiveValues, textBudget);
const supportManifest = await safeReadBoundedJson(path.join(baseDir, SUPPORT_MANIFEST_FILE), MAX_RUNTIME_FILE_BYTES);
if (supportManifest) {