fix: harden automation reconciliation and pause lifecycle

Keep full folder scans recoverable under duplicate protection and classify every discovered file once before automatic admission.

Separate manual previews from automatic capacity limits, serialize renderer intake, normalize automation counters and intervals, and make Main authoritative for runtime timestamps.

Enforce persistent pause across startup, import, close recovery, resume failures, and reconciliation while preserving read-only paused scans and exactly one activation reconciliation.

Restore failed asynchronous rotation chunks, complete automation localization and unlimited queue accessibility, and tighten hidden integration cleanup coverage.
This commit is contained in:
Sucukdeluxe
2026-08-26 15:47:31 +02:00
parent ca2d1f8fda
commit afd3b70cf3
12 changed files with 812 additions and 157 deletions
+16 -7
View File
@@ -25,13 +25,22 @@
return Math.max(1, Math.floor(number)); return Math.max(1, Math.floor(number));
} }
function normalizeReconcileInterval(value) {
return typeof value === 'number' && Number.isFinite(value) && allowedIntervals.has(value) ? value : 5;
}
function normalizeCounter(value) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) return 0;
return Math.min(Number.MAX_SAFE_INTEGER, Math.floor(number));
}
function normalizeAutomationSettings(value = {}) { function normalizeAutomationSettings(value = {}) {
const settings = asObject(value); const settings = asObject(value);
const queueLimitJobs = normalizeQueueLimit(settings.queueLimitJobs); const queueLimitJobs = normalizeQueueLimit(settings.queueLimitJobs);
const rawInterval = Number(settings.reconcileIntervalMinutes);
return { return {
queueLimitJobs, queueLimitJobs,
reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5, reconcileIntervalMinutes: normalizeReconcileInterval(settings.reconcileIntervalMinutes),
paused: settings.paused === true, paused: settings.paused === true,
pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null
}; };
@@ -100,10 +109,10 @@
...emptyTelemetry(dateKey), ...emptyTelemetry(dateKey),
...telemetry, ...telemetry,
dateKey, dateKey,
detected: Math.max(0, Number(telemetry.detected) || 0), detected: normalizeCounter(telemetry.detected),
queued: Math.max(0, Number(telemetry.queued) || 0), queued: normalizeCounter(telemetry.queued),
skipped: Math.max(0, Number(telemetry.skipped) || 0), skipped: normalizeCounter(telemetry.skipped),
deferred: Math.max(0, Number(telemetry.deferred) || 0) deferred: normalizeCounter(telemetry.deferred)
}; };
} }
@@ -111,7 +120,7 @@
const changes = asObject(delta); const changes = asObject(delta);
const next = rollDailyTelemetry(value, nowMs); const next = rollDailyTelemetry(value, nowMs);
for (const key of ['detected', 'queued', 'skipped', 'deferred']) { for (const key of ['detected', 'queued', 'skipped', 'deferred']) {
next[key] += Math.max(0, Number(changes[key]) || 0); next[key] = Math.min(Number.MAX_SAFE_INTEGER, next[key] + normalizeCounter(changes[key]));
} }
if (changes.lastDetectedName) { if (changes.lastDetectedName) {
next.lastDetectedName = String(changes.lastDetectedName); next.lastDetectedName = String(changes.lastDetectedName);
+42 -9
View File
@@ -3,6 +3,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { walkFolderAsync } = require('./file-discovery'); const { walkFolderAsync } = require('./file-discovery');
const { normalizeAutomationSettings } = require('./automation-control');
class FolderMonitor extends EventEmitter { class FolderMonitor extends EventEmitter {
constructor({ constructor({
@@ -44,6 +45,9 @@ class FolderMonitor extends EventEmitter {
this._lastScanAt = null; this._lastScanAt = null;
this._lastScanTrigger = ''; this._lastScanTrigger = '';
this._lastError = ''; this._lastError = '';
this._startedAt = null;
this._nextReconcileAt = null;
this._reconcileIntervalMs = 5 * 60 * 1000;
this._generation = 0; this._generation = 0;
} }
@@ -55,6 +59,24 @@ class FolderMonitor extends EventEmitter {
return this._start(settings, false); return this._start(settings, false);
} }
configure(settings) {
const folderPath = String(settings?.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes;
const watcher = this._invalidateLifecycle(true);
if (watcher) {
try {
Promise.resolve(watcher.close()).catch(() => {});
} catch {}
}
this._seenFiles = new Set();
this._settings = { ...settings, folderPath, reconcileIntervalMinutes };
this._reachable = null;
this._lastError = '';
this._emitStatus(this._generation);
return { includesExisting: false, paused: true };
}
stop() { stop() {
this._deactivate({ clearSeen: true, paused: false }); this._deactivate({ clearSeen: true, paused: false });
} }
@@ -67,7 +89,9 @@ class FolderMonitor extends EventEmitter {
scanning: this._scanning, scanning: this._scanning,
folderPath: this._settings ? this._settings.folderPath : '', folderPath: this._settings ? this._settings.folderPath : '',
seenCount: this._seenFiles.size, seenCount: this._seenFiles.size,
startedAt: this._startedAt,
lastScanAt: this._lastScanAt, lastScanAt: this._lastScanAt,
nextReconcileAt: this._nextReconcileAt,
lastScanTrigger: this._lastScanTrigger, lastScanTrigger: this._lastScanTrigger,
error: this._lastError error: this._lastError
}); });
@@ -106,19 +130,22 @@ class FolderMonitor extends EventEmitter {
if (changed) this._emitStatus(this._generation); if (changed) this._emitStatus(this._generation);
} }
async resume(settings = this._settings) { async resume(settings = this._settings, options = {}) {
if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden'); if (!settings) throw new Error('Keine Ordnerkonfiguration vorhanden');
this._start(settings, true); this._start(settings, true);
if (options.reconcile === false) return { reconciled: false };
return this.scan({ emitFiles: true, trigger: 'resume' }); return this.scan({ emitFiles: true, trigger: 'resume' });
} }
_start(settings, preserveSeen) { _start(settings, preserveSeen) {
this._deactivate({ clearSeen: !preserveSeen, paused: false }); this._deactivate({ clearSeen: !preserveSeen, paused: false });
this._settings = settings; const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes;
this._settings = { ...settings, reconcileIntervalMinutes };
this._paused = false; this._paused = false;
this._reachable = null; this._reachable = null;
this._lastError = ''; this._lastError = '';
settings = this._settings;
const folderPath = String(settings.folderPath || '').trim(); const folderPath = String(settings.folderPath || '').trim();
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben'); if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
@@ -158,10 +185,12 @@ class FolderMonitor extends EventEmitter {
this._emitStatus(generation); this._emitStatus(generation);
this._emitEvent('error', [error], generation); this._emitEvent('error', [error], generation);
}); });
const intervalMinutes = Number(settings.reconcileIntervalMinutes) || 5; this._reconcileIntervalMs = reconcileIntervalMinutes * 60 * 1000;
this._startedAt = this._now();
this._nextReconcileAt = this._startedAt + this._reconcileIntervalMs;
this._reconcileTimer = this._setInterval( this._reconcileTimer = this._setInterval(
() => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)), () => this._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)),
intervalMinutes * 60 * 1000 this._reconcileIntervalMs
); );
this._emitStatus(generation); this._emitStatus(generation);
return { includesExisting: includeInitial }; return { includesExisting: includeInitial };
@@ -194,6 +223,8 @@ class FolderMonitor extends EventEmitter {
} }
this._batchSeenReservations.clear(); this._batchSeenReservations.clear();
this._paused = paused; this._paused = paused;
this._startedAt = null;
this._nextReconcileAt = null;
this._scanning = false; this._scanning = false;
this._followUpRequested = false; this._followUpRequested = false;
this._followUpOptions = null; this._followUpOptions = null;
@@ -280,9 +311,8 @@ class FolderMonitor extends EventEmitter {
try { try {
const files = await this._discoverFiles(settings, generation); const files = await this._discoverFiles(settings, generation);
if (!this._isCurrent(generation)) return this._cancelledResult(trigger); if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
const emittedFiles = files.filter((file) => this._acceptPath(file.path)); if (files.length > 0) {
if (emittedFiles.length > 0) { const listenerError = this._emitEvent('new-files', [files], generation);
const listenerError = this._emitEvent('new-files', [emittedFiles.map((file) => file.path)], generation);
if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true); if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true);
} }
if (!this._isCurrent(generation)) return this._cancelledResult(trigger); if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
@@ -325,7 +355,7 @@ class FolderMonitor extends EventEmitter {
for (const descriptor of discovered) { for (const descriptor of discovered) {
if (!this._isCurrent(generation)) return []; if (!this._isCurrent(generation)) return [];
if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue; if (!settings.recursive && this._isNestedPath(descriptor.path, settings.folderPath)) continue;
if (!this._classifyPath(descriptor.path, settings).allowed) continue; const classification = this._classifyPath(descriptor.path, settings);
let mtimeMs = 0; let mtimeMs = 0;
try { try {
mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0; mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0;
@@ -335,7 +365,9 @@ class FolderMonitor extends EventEmitter {
path: descriptor.path, path: descriptor.path,
name: descriptor.name || path.basename(descriptor.path), name: descriptor.name || path.basename(descriptor.path),
size: Number(descriptor.size) || 0, size: Number(descriptor.size) || 0,
mtimeMs mtimeMs,
filterMatched: classification.allowed,
filterReason: classification.reason
})); }));
} }
return files; return files;
@@ -353,6 +385,7 @@ class FolderMonitor extends EventEmitter {
async _reconcile(generation) { async _reconcile(generation) {
if (!this._acceptCallback(generation)) return; if (!this._acceptCallback(generation)) return;
this._nextReconcileAt = this._now() + this._reconcileIntervalMs;
const trigger = this._reachable === false ? 'reconnect' : 'interval'; const trigger = this._reachable === false ? 'reconnect' : 'interval';
await this.scan({ emitFiles: true, trigger }); await this.scan({ emitFiles: true, trigger });
} }
+54 -1
View File
@@ -105,8 +105,61 @@ function createUploadAuditWriter(options) {
return createInternalLogWriter({ ...options, fileName: 'upload-audit.log' }); return createInternalLogWriter({ ...options, fileName: 'upload-audit.log' });
} }
function createBufferedInternalLogFlusher(options) {
const source = options && typeof options === 'object' ? options : {};
const buffer = source.buffer;
const writer = source.writer;
const schedule = typeof source.schedule === 'function' ? source.schedule : setImmediate;
const reportError = typeof source.reportError === 'function' ? source.reportError : () => {};
let writing = false;
if (!Array.isArray(buffer) || !writer || typeof writer.append !== 'function' || typeof writer.flushSync !== 'function') {
throw new TypeError('createBufferedInternalLogFlusher requires buffer and writer');
}
function restoreChunk(chunk) {
for (let end = chunk.length; end > 0;) {
const start = Math.max(0, end - 1024);
buffer.splice(0, 0, ...chunk.slice(start, end));
end = start;
}
}
async function flush(label) {
if (writing || buffer.length === 0) return null;
const chunk = buffer.splice(0);
writing = true;
let written = false;
try {
written = await writer.append(chunk.join(''), label);
} catch (error) {
reportError(label, error);
} finally {
writing = false;
}
if (!written) {
restoreChunk(chunk);
return false;
}
if (buffer.length > 0) {
try {
schedule(() => { void flush(label); });
} catch (error) {
reportError(label, error);
}
}
return true;
}
return {
flush,
flushSync: label => writer.flushSync(buffer, label),
isWriting: () => writing
};
}
function getLogOpenDirectory(targetPath, fallbackDirectory, pathApi = nodePath) { function getLogOpenDirectory(targetPath, fallbackDirectory, pathApi = nodePath) {
return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory; return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory;
} }
module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory }; module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory };
+83 -45
View File
@@ -30,7 +30,7 @@ const RemoteServer = require('./lib/remote-server');
const { maybeRotateLogFile } = require('./lib/log-rotation'); const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { hosterLogToFileEnabled } = require('./lib/log-policy');
const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log'); const { formatUploadLogLine, parseUploadLogLine, summarizeBatchPlan, formatUploadPlanLogLine } = require('./lib/upload-log');
const { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory } = require('./lib/upload-audit'); const { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory } = require('./lib/upload-audit');
const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { selectOrphanTmps } = require('./lib/orphan-tmp');
const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
@@ -39,6 +39,7 @@ const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent'); const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight'); const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight');
const { normalizeAutomationSettings } = require('./lib/automation-control');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable(); _eventLoopDelay.enable();
@@ -289,7 +290,9 @@ function restoreClosePreparation(attempt, clearRestart = true) {
if (closeFolderMonitorWasRunning) { if (closeFolderMonitorWasRunning) {
closeFolderMonitorWasRunning = false; closeFolderMonitorWasRunning = false;
const settings = configStore.load().globalSettings?.folderMonitor; const settings = configStore.load().globalSettings?.folderMonitor;
if (settings?.enabled && settings.folderPath) startFolderMonitor(settings); if (settings?.enabled && settings.folderPath) {
void startFolderMonitor(settings).catch(error => debugLog(`folder-monitor close recovery failed: ${error.message}`));
}
} }
rejectPendingUpdate(new Error('Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten')); rejectPendingUpdate(new Error('Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten'));
return true; return true;
@@ -558,21 +561,16 @@ function getRotLogPath() {
} }
const _rotLogBuffer = []; const _rotLogBuffer = [];
let _rotLogFlushTimer = null; let _rotLogFlushTimer = null;
let _rotLogWriting = false; const _rotLogFlusher = createBufferedInternalLogFlusher({
buffer: _rotLogBuffer,
writer: _rotLogWriter,
schedule: setImmediate,
reportError: (label, error) => debugLog(`${label} append failed: ${error.message}`)
});
function _flushRotLog() { function _flushRotLog() {
if (_rotLogWriting || _rotLogBuffer.length === 0) return; void _rotLogFlusher.flush('rot-log').then(written => {
const chunk = _rotLogBuffer.join(''); if (written === false) debugLog('rot-log append failed: no writable target');
_rotLogBuffer.length = 0;
_rotLogWriting = true;
_rotLogWriter.append(chunk, 'rot-log').then(written => {
_rotLogWriting = false;
if (!written) debugLog('rot-log append failed: no writable target');
if (_rotLogBuffer.length) setImmediate(_flushRotLog);
}, error => {
_rotLogWriting = false;
debugLog(`rot-log append failed: ${error.message}`);
if (_rotLogBuffer.length) setImmediate(_flushRotLog);
}); });
} }
@@ -1686,13 +1684,8 @@ app.whenReady().then(async () => {
try { try {
const launchConfig = configStore.load(); const launchConfig = configStore.load();
const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor;
if (fm && fm.enabled && fm.folderPath && fm.paused !== true) { if (fm && fm.enabled && fm.folderPath) {
startFolderMonitor(fm); await startFolderMonitor(fm);
if (!fs.existsSync(fm.folderPath)) {
void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => {
debugLog(`folder-monitor startup scan failed: ${error.message}`);
});
}
} }
} catch (err) { } catch (err) {
debugLog(`folder-monitor auto-start failed: ${err.message}`); debugLog(`folder-monitor auto-start failed: ${err.message}`);
@@ -1785,7 +1778,7 @@ app.on('will-quit', () => {
} catch {} } catch {}
try { try {
if (_rotLogBuffer.length) { if (_rotLogBuffer.length) {
_rotLogWriter.flushSync(_rotLogBuffer, 'rot-log'); _rotLogFlusher.flushSync('rot-log');
} }
} catch {} } catch {}
}); });
@@ -2802,7 +2795,7 @@ async function syncImportedRuntime(config) {
try { try {
folderMonitor.stop(); folderMonitor.stop();
const folderSettings = config.globalSettings.folderMonitor; const folderSettings = config.globalSettings.folderMonitor;
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) startFolderMonitor(folderSettings); if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings);
} catch (error) { } catch (error) {
debugLog(`backup folder monitor sync failed: ${error.message}`); debugLog(`backup folder monitor sync failed: ${error.message}`);
warnings.push('Ordnerüberwachung'); warnings.push('Ordnerüberwachung');
@@ -3273,14 +3266,15 @@ async function withAutomationStatusSuppressed(operation) {
function automationStatusSnapshot() { function automationStatusSnapshot() {
const settings = configStore.load().globalSettings?.folderMonitor || {}; const settings = configStore.load().globalSettings?.folderMonitor || {};
const normalized = normalizeAutomationSettings(settings);
return Object.freeze({ return Object.freeze({
...folderMonitor.status(), ...folderMonitor.status(),
enabled: settings.enabled === true, enabled: settings.enabled === true,
configured: String(settings.folderPath || '').trim().length > 0, configured: String(settings.folderPath || '').trim().length > 0,
paused: settings.paused === true, paused: settings.paused === true,
pausedAt: settings.pausedAt ?? null, pausedAt: settings.pausedAt ?? null,
queueLimitJobs: settings.queueLimitJobs, queueLimitJobs: normalized.queueLimitJobs,
reconcileIntervalMinutes: settings.reconcileIntervalMinutes reconcileIntervalMinutes: normalized.reconcileIntervalMinutes
}); });
} }
@@ -3318,11 +3312,25 @@ function bindFolderMonitorEvents(settings) {
}); });
} }
function startFolderMonitor(settings) { async function startFolderMonitor(settings) {
try { try {
folderMonitor.stop(); const persisted = configStore.load().globalSettings?.folderMonitor || {};
bindFolderMonitorEvents(settings); const normalized = normalizeAutomationSettings(settings);
const result = folderMonitor.start(settings); const effectiveSettings = {
...settings,
queueLimitJobs: normalized.queueLimitJobs,
reconcileIntervalMinutes: normalized.reconcileIntervalMinutes,
paused: persisted.paused === true,
pausedAt: persisted.paused === true ? (persisted.pausedAt ?? null) : null
};
bindFolderMonitorEvents(effectiveSettings);
if (persisted.paused === true) {
folderMonitor.configure(effectiveSettings);
debugLog(`folder-monitor configured while paused: ${effectiveSettings.folderPath}`);
return { includesExisting: false, paused: true };
}
const result = folderMonitor.start(effectiveSettings);
await folderMonitor.scan({ emitFiles: true, trigger: 'startup' });
debugLog(`folder-monitor started: ${settings.folderPath}`); debugLog(`folder-monitor started: ${settings.folderPath}`);
return result; return result;
} catch (err) { } catch (err) {
@@ -3333,16 +3341,14 @@ function startFolderMonitor(settings) {
async function resumeFolderMonitor(settings) { async function resumeFolderMonitor(settings) {
bindFolderMonitorEvents(settings); bindFolderMonitorEvents(settings);
const result = await folderMonitor.resume(settings); const result = await folderMonitor.resume(settings, { reconcile: false });
debugLog(`folder-monitor resumed: ${settings.folderPath}`); debugLog(`folder-monitor resumed: ${settings.folderPath}`);
return result; return result;
} }
ipcMain.handle('folder-monitor:start', (_event, settings) => { ipcMain.handle('folder-monitor:start', async (_event, settings) => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) { const result = await startFolderMonitor(settings);
return { error: 'Automatik ist pausiert' }; if (result?.paused === true) return { error: 'Automatik ist pausiert' };
}
const result = startFolderMonitor(settings);
return { ok: true, includesExisting: result?.includesExisting === true }; return { ok: true, includesExisting: result?.includesExisting === true };
}); });
@@ -3383,21 +3389,50 @@ ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle
})); }));
ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => { ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => {
let result;
await withAutomationStatusSuppressed(async () => { await withAutomationStatusSuppressed(async () => {
const latest = configStore.load(); const latest = configStore.load();
const settings = latest.globalSettings?.folderMonitor || {}; const settings = latest.globalSettings?.folderMonitor || {};
const resumedSettings = { ...settings, paused: false, pausedAt: null }; const normalized = normalizeAutomationSettings(settings);
await configStore.save({ const pausedSettings = {
globalSettings: { ...settings,
...latest.globalSettings, queueLimitJobs: normalized.queueLimitJobs,
folderMonitor: resumedSettings reconcileIntervalMinutes: normalized.reconcileIntervalMinutes,
paused: settings.paused === true,
pausedAt: settings.pausedAt ?? null
};
const resumedSettings = { ...pausedSettings, paused: false, pausedAt: null };
try {
if (resumedSettings.enabled && resumedSettings.folderPath) {
await resumeFolderMonitor(resumedSettings);
} }
}); await configStore.save({
if (resumedSettings.enabled && resumedSettings.folderPath) { globalSettings: {
await resumeFolderMonitor(resumedSettings); ...latest.globalSettings,
folderMonitor: resumedSettings
}
});
if (resumedSettings.enabled && resumedSettings.folderPath) {
await folderMonitor.scan({ emitFiles: true, trigger: 'resume' });
}
} catch {
folderMonitor.stop();
if (pausedSettings.enabled && pausedSettings.folderPath) {
bindFolderMonitorEvents(pausedSettings);
folderMonitor.configure(pausedSettings);
}
try {
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: pausedSettings
}
});
} catch {}
result = { error: 'Automatik konnte nicht fortgesetzt werden' };
} }
}); });
return publishAutomationStatus(null, generation); return publishAutomationStatus(result, generation);
})); }));
ipcMain.handle('folder-monitor:test-scan', () => { ipcMain.handle('folder-monitor:test-scan', () => {
@@ -3405,6 +3440,9 @@ ipcMain.handle('folder-monitor:test-scan', () => {
}); });
ipcMain.handle('folder-monitor:reconcile', () => { ipcMain.handle('folder-monitor:reconcile', () => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
return folderMonitor.scan({ emitFiles: true, trigger: 'manual' }); return folderMonitor.scan({ emitFiles: true, trigger: 'manual' });
}); });
+99 -39
View File
@@ -67,12 +67,15 @@ let uploading = false;
let healthCheckRunning = false; let healthCheckRunning = false;
let automationRuntimeStatus = Object.freeze({}); let automationRuntimeStatus = Object.freeze({});
let automationRuntimeStatusAvailable = false; let automationRuntimeStatusAvailable = false;
let automationRuntimeStartedAt = null;
let automationPauseResumeBusy = false; let automationPauseResumeBusy = false;
let automationTestGeneration = 0; let automationTestGeneration = 0;
let automationTestReturnFocus = null; let automationTestReturnFocus = null;
let automationTestInertState = []; let automationTestInertState = [];
let automationTestViewState = Object.freeze({ loading: false, summary: null, error: '' }); let automationTestViewState = Object.freeze({ loading: false, summary: null, error: '' });
const automationEventBatchSize = 8;
const automationEventQueue = new Map();
const automationEventInFlight = new Set();
let automationEventDrainPromise = null;
let managedOnlineBackups = []; let managedOnlineBackups = [];
let managedOnlineBackupsAuthoritative = false; let managedOnlineBackupsAuthoritative = false;
let managedOnlineBackupMutationGeneration = 0; let managedOnlineBackupMutationGeneration = 0;
@@ -482,14 +485,6 @@ function automationSettings() {
function applyAutomationRuntimeStatus(value) { function applyAutomationRuntimeStatus(value) {
const next = { ...(value || {}) }; const next = { ...(value || {}) };
const incomingStartedAt = automationTimestamp(next.startedAt);
const wasRunning = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true;
if (next.running === true && next.paused !== true) {
automationRuntimeStartedAt = incomingStartedAt || (wasRunning ? automationRuntimeStartedAt : null) || Date.now();
} else if (incomingStartedAt) {
automationRuntimeStartedAt = incomingStartedAt;
}
if (automationRuntimeStartedAt) next.startedAt = automationRuntimeStartedAt;
if (typeof next.paused === 'boolean' && config.globalSettings) { if (typeof next.paused === 'boolean' && config.globalSettings) {
const folderMonitor = config.globalSettings.folderMonitor || {}; const folderMonitor = config.globalSettings.folderMonitor || {};
config.globalSettings.folderMonitor = { config.globalSettings.folderMonitor = {
@@ -532,11 +527,9 @@ function createAutomationStatusSnapshot() {
&& availableSlots < configuredTargetCount; && availableSlots < configuredTargetCount;
const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry); const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry);
const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || ''); const error = String(automationRuntimeStatus.error || automationRuntimeStatus.monitorError || telemetry.lastError || '');
const startedAt = automationTimestamp(automationRuntimeStatus.startedAt) || automationRuntimeStartedAt; const startedAt = automationTimestamp(automationRuntimeStatus.startedAt);
const lastReconcileAt = automationTimestamp(automationRuntimeStatus.lastScanAt); const lastReconcileAt = automationTimestamp(automationRuntimeStatus.lastScanAt);
const nextReconcileAt = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true && lastReconcileAt const nextReconcileAt = automationTimestamp(automationRuntimeStatus.nextReconcileAt);
? lastReconcileAt + normalized.reconcileIntervalMinutes * 60000
: null;
const snapshot = { const snapshot = {
...automationRuntimeStatus, ...automationRuntimeStatus,
enabled: folderSettings.enabled === true, enabled: folderSettings.enabled === true,
@@ -569,6 +562,9 @@ async function evaluateAutomationCandidates(files, options = {}) {
} }
const candidates = [...candidateMap.values()]; const candidates = [...candidateMap.values()];
const matched = candidates.filter(candidate => candidate.path && candidate.filterMatched); const matched = candidates.filter(candidate => candidate.path && candidate.filterMatched);
const reasons = new Map(candidates
.filter(candidate => !candidate.path || !candidate.filterMatched)
.map(candidate => [normalizeAutomationPath(candidate.path), 'filter-rejected']));
const folderSettings = config.globalSettings?.folderMonitor || {}; const folderSettings = config.globalSettings?.folderMonitor || {};
const ownedPendingPaths = new Set((Array.isArray(options.ownedPendingPaths) ? options.ownedPendingPaths : []).map(normalizeAutomationPath)); const ownedPendingPaths = new Set((Array.isArray(options.ownedPendingPaths) ? options.ownedPendingPaths : []).map(normalizeAutomationPath));
const selectedHosters = Array.from(new Set((Array.isArray(options.selectedHosters) ? options.selectedHosters : folderSettings.hosters || []) const selectedHosters = Array.from(new Set((Array.isArray(options.selectedHosters) ? options.selectedHosters : folderSettings.hosters || [])
@@ -585,12 +581,17 @@ async function evaluateAutomationCandidates(files, options = {}) {
uploadLogRows: uploadLog uploadLogRows: uploadLog
}); });
const processedPaths = new Set(processed.processedPaths.map(normalizeAutomationPath)); const processedPaths = new Set(processed.processedPaths.map(normalizeAutomationPath));
for (const path of processedPaths) reasons.set(path, 'processed');
const unprocessed = matched.filter(candidate => !processedPaths.has(normalizeAutomationPath(candidate.path))); const unprocessed = matched.filter(candidate => !processedPaths.has(normalizeAutomationPath(candidate.path)));
const inspection = await window.api.inspectImportFiles( const inspection = await window.api.inspectImportFiles(
unprocessed, unprocessed,
_pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path) _pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path)
); );
const metadata = new Map(unprocessed.map(candidate => [normalizeAutomationPath(candidate.path), candidate])); const metadata = new Map(unprocessed.map(candidate => [normalizeAutomationPath(candidate.path), candidate]));
const inspectionDuplicatePaths = new Set((Array.isArray(inspection?.duplicates) ? inspection.duplicates : []).map(file => normalizeAutomationPath(file?.path)).filter(Boolean));
const unavailablePaths = new Set((Array.isArray(inspection?.unavailable) ? inspection.unavailable : []).map(file => normalizeAutomationPath(file?.path)).filter(Boolean));
for (const path of inspectionDuplicatePaths) reasons.set(path, 'inspection-duplicate');
for (const path of unavailablePaths) reasons.set(path, 'unavailable');
const accepted = (Array.isArray(inspection?.accepted) ? inspection.accepted : []).map(file => ({ const accepted = (Array.isArray(inspection?.accepted) ? inspection.accepted : []).map(file => ({
...(metadata.get(normalizeAutomationPath(file?.path)) || {}), ...(metadata.get(normalizeAutomationPath(file?.path)) || {}),
...file ...file
@@ -610,7 +611,7 @@ async function evaluateAutomationCandidates(files, options = {}) {
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
const availableSlots = normalizedSettings.queueLimitJobs === 0 ? null : Math.max(0, normalizedSettings.queueLimitJobs - currentJobCount); const availableSlots = normalizedSettings.queueLimitJobs === 0 ? null : Math.max(0, normalizedSettings.queueLimitJobs - currentJobCount);
const admission = window.AutomationControl.planAtomicAdmissions({ const admission = window.AutomationControl.planAtomicAdmissions({
candidates: plannedCandidates, candidates: plannedCandidates.filter(candidate => candidate.eligibleJobCount > 0),
currentJobCount, currentJobCount,
queueLimitJobs: normalizedSettings.queueLimitJobs queueLimitJobs: normalizedSettings.queueLimitJobs
}); });
@@ -618,14 +619,28 @@ async function evaluateAutomationCandidates(files, options = {}) {
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath)); const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
const admittedFiles = plannedCandidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path))); const admittedFiles = plannedCandidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path)));
const deferredFiles = plannedCandidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path))); const deferredFiles = plannedCandidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path)));
const resultingJobs = plannedCandidates.reduce((total, candidate) => total + candidate.eligibleJobCount, 0); for (const candidate of plannedCandidates) {
const key = normalizeAutomationPath(candidate.path);
if (selectedHosters.length === 0) reasons.set(key, 'awaiting-host-selection');
else if (candidate.eligibleJobCount === 0) reasons.set(key, 'size-limited');
else if (admittedPaths.has(key)) reasons.set(key, 'admitted');
else if (deferredPaths.has(key)) reasons.set(key, 'deferred');
}
const actionableCandidates = plannedCandidates.filter(candidate => candidate.eligibleJobCount > 0);
const resultingJobs = actionableCandidates.reduce((total, candidate) => total + candidate.eligibleJobCount, 0);
const classifications = candidates.map(candidate => ({
path: candidate.path,
name: candidate.name,
reason: reasons.get(normalizeAutomationPath(candidate.path)) || 'unavailable'
}));
const skippedReasons = new Set(['filter-rejected', 'processed', 'inspection-duplicate', 'unavailable', 'size-limited']);
const summary = { const summary = {
found: candidates.length, found: candidates.length,
filterMatched: matched.length, filterMatched: matched.length,
alreadyProcessed: processed.processedPaths.length, alreadyProcessed: processed.processedPaths.length + inspectionDuplicatePaths.size,
unavailable: Number(inspection?.unavailableCount) || 0, unavailable: unavailablePaths.size,
sizeLimitedJobs: plannedCandidates.length * selectedHosters.length - resultingJobs, sizeLimitedJobs: plannedCandidates.length * selectedHosters.length - resultingJobs,
acceptedFiles: plannedCandidates.length, acceptedFiles: selectedHosters.length === 0 ? plannedCandidates.length : actionableCandidates.length,
selectedTargets: selectedHosters.length, selectedTargets: selectedHosters.length,
resultingJobs, resultingJobs,
availableSlots, availableSlots,
@@ -639,15 +654,16 @@ async function evaluateAutomationCandidates(files, options = {}) {
selectedHosters, selectedHosters,
ownedPendingPaths: [...ownedPendingPaths], ownedPendingPaths: [...ownedPendingPaths],
candidates: plannedCandidates, candidates: plannedCandidates,
classifications,
admittedFiles, admittedFiles,
deferredFiles, deferredFiles,
summary, summary,
telemetryDelta: { telemetryDelta: {
detected: summary.found, detected: summary.found,
queued: admittedFiles.length, queued: admittedFiles.length,
skipped: summary.alreadyProcessed + summary.unavailable, skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).length,
deferred: deferredFiles.length, deferred: deferredFiles.length,
lastDetectedName: plannedCandidates.at(-1)?.name || '' lastDetectedName: candidates.at(-1)?.name || ''
} }
}); });
} }
@@ -740,7 +756,7 @@ async function applyAutomationEvaluation(evaluation) {
const normalizedSettings = automationSettings(); const normalizedSettings = automationSettings();
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs); const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
const admission = window.AutomationControl.planAtomicAdmissions({ const admission = window.AutomationControl.planAtomicAdmissions({
candidates, candidates: candidates.filter(candidate => candidate.eligibleJobCount > 0),
currentJobCount, currentJobCount,
queueLimitJobs: normalizedSettings.queueLimitJobs queueLimitJobs: normalizedSettings.queueLimitJobs
}); });
@@ -748,12 +764,25 @@ async function applyAutomationEvaluation(evaluation) {
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath)); const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path))); const admittedFiles = candidates.filter(candidate => admittedPaths.has(normalizeAutomationPath(candidate.path)));
const deferredFiles = candidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path))); const deferredFiles = candidates.filter(candidate => deferredPaths.has(normalizeAutomationPath(candidate.path)));
const dynamicPaths = new Set(replannedCandidates.map(candidate => normalizeAutomationPath(candidate.path)));
const dynamicReasons = new Map();
for (const candidate of replannedCandidates) {
const key = normalizeAutomationPath(candidate.path);
if (currentPaths.has(key)) dynamicReasons.set(key, 'inspection-duplicate');
else if (candidate.eligibleJobCount === 0) dynamicReasons.set(key, 'size-limited');
else if (admittedPaths.has(key)) dynamicReasons.set(key, 'admitted');
else dynamicReasons.set(key, 'deferred');
}
const classifications = (evaluation.classifications || []).map(entry => dynamicPaths.has(normalizeAutomationPath(entry.path))
? { ...entry, reason: dynamicReasons.get(normalizeAutomationPath(entry.path)) }
: entry);
const skippedReasons = new Set(['filter-rejected', 'processed', 'inspection-duplicate', 'unavailable', 'size-limited']);
const telemetryDelta = { const telemetryDelta = {
detected: evaluation.summary.found, detected: classifications.length,
queued: admittedFiles.length, queued: admittedFiles.length,
skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).length,
deferred: deferredFiles.length, deferred: deferredFiles.length,
lastDetectedName: admittedFiles.at(-1)?.name || '' lastDetectedName: evaluation.telemetryDelta?.lastDetectedName || ''
}; };
if (admittedFiles.length === 0) { if (admittedFiles.length === 0) {
const telemetryResult = await persistAutomationTelemetry(telemetryDelta); const telemetryResult = await persistAutomationTelemetry(telemetryDelta);
@@ -907,9 +936,13 @@ function renderAutomationStatusSnapshot(snapshot) {
} }
const queueTrack = document.getElementById('automationQueueMeterTrack'); const queueTrack = document.getElementById('automationQueueMeterTrack');
if (queueTrack) { if (queueTrack) {
queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount)); if (snapshot.queueLimitJobs === 0) {
if (snapshot.queueLimitJobs === 0) queueTrack.removeAttribute('aria-valuemax'); queueTrack.removeAttribute('aria-valuenow');
else queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs)); queueTrack.removeAttribute('aria-valuemax');
} else {
queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount));
queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs));
}
queueTrack.setAttribute('aria-valuetext', queueText); queueTrack.setAttribute('aria-valuetext', queueText);
} }
const telemetry = snapshot.telemetry || {}; const telemetry = snapshot.telemetry || {};
@@ -925,7 +958,7 @@ function renderAutomationStatusSnapshot(snapshot) {
const errorRow = document.getElementById('automationLastErrorRow'); const errorRow = document.getElementById('automationLastErrorRow');
const errorText = String(snapshot.error || telemetry.lastError || ''); const errorText = String(snapshot.error || telemetry.lastError || '');
if (errorRow) errorRow.hidden = errorText.length === 0; if (errorRow) errorRow.hidden = errorText.length === 0;
setAutomationText('automationLastError', errorText); setAutomationText('automationLastError', errorText ? localizeUiText(errorText) : '');
return snapshot; return snapshot;
} }
@@ -1151,7 +1184,7 @@ async function toggleAutomationPauseResume() {
updateStatusBar(); updateStatusBar();
} }
} catch { } catch {
showCopyToast(resume ? 'Automatik konnte nicht fortgesetzt werden.' : 'Automatik konnte nicht pausiert werden.'); showCopyToast(localizeUiText(resume ? 'Automatik konnte nicht fortgesetzt werden.' : 'Automatik konnte nicht pausiert werden.'));
} finally { } finally {
automationPauseResumeBusy = false; automationPauseResumeBusy = false;
refreshAutomationControlCenter(); refreshAutomationControlCenter();
@@ -1166,11 +1199,12 @@ window.runFolderMonitorTestScan = runFolderMonitorTestScan;
function surfaceAutomationOutcome(result) { function surfaceAutomationOutcome(result) {
if (result?.ok !== false) return ''; if (result?.ok !== false) return '';
const message = result.warning || result.error || 'Automatische Aufnahme konnte nicht abgeschlossen werden.'; const message = result.warning || result.error || 'Automatische Aufnahme konnte nicht abgeschlossen werden.';
showCopyToast(message, 6500); const localized = localizeUiText(message);
return message; showCopyToast(localized, 6500);
return localized;
} }
async function handleFolderMonitorFiles(files) { async function processFolderMonitorFiles(files) {
window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)');
const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' }); const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' });
const result = await applyAutomationEvaluation(evaluation); const result = await applyAutomationEvaluation(evaluation);
@@ -1178,6 +1212,38 @@ async function handleFolderMonitorFiles(files) {
return result; return result;
} }
async function drainFolderMonitorFiles() {
let result = freezeAutomationValue({ admittedFiles: [], deferredFiles: [], paused: false, dryRun: false });
while (automationEventQueue.size > 0) {
const entries = [...automationEventQueue.entries()].slice(0, automationEventBatchSize);
for (const [key] of entries) {
automationEventQueue.delete(key);
automationEventInFlight.add(key);
}
try {
result = await processFolderMonitorFiles(entries.map(([, file]) => file));
} finally {
for (const [key] of entries) automationEventInFlight.delete(key);
}
}
return result;
}
function handleFolderMonitorFiles(files) {
for (const file of Array.isArray(files) ? files : []) {
const candidate = normalizeAutomationCandidate(file, automationEventQueue.size);
const key = normalizeAutomationPath(candidate.path);
if (!key || automationEventQueue.has(key) || automationEventInFlight.has(key)) continue;
automationEventQueue.set(key, candidate);
}
if (!automationEventDrainPromise) {
automationEventDrainPromise = Promise.resolve()
.then(drainFolderMonitorFiles)
.finally(() => { automationEventDrainPromise = null; });
}
return automationEventDrainPromise;
}
// --- Init --- // --- Init ---
async function init() { async function init() {
try { try {
@@ -2515,10 +2581,6 @@ function suppressPreviewKeysStillSelected(keys) {
function buildQueuePreview() { function buildQueuePreview() {
const hosters = getSelectedHosters(); const hosters = getSelectedHosters();
queueJobs = queueJobs.filter(j => j.status !== 'preview' || j.automationAdmission === true); queueJobs = queueJobs.filter(j => j.status !== 'preview' || j.automationAdmission === true);
const normalizedSettings = automationSettings();
let availableSlots = normalizedSettings.queueLimitJobs === 0
? null
: Math.max(0, normalizedSettings.queueLimitJobs - window.AutomationControl.countAutomaticQueueJobs(queueJobs));
if (hosters.length > 0) { if (hosters.length > 0) {
const existingKeys = new Set(); const existingKeys = new Set();
@@ -2532,7 +2594,6 @@ function buildQueuePreview() {
const key = `${file.path}|${hoster}`; const key = `${file.path}|${hoster}`;
return !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key); return !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key);
}); });
if (availableSlots !== null && eligibleHosters.length > availableSlots) continue;
for (const hoster of eligibleHosters) { for (const hoster of eligibleHosters) {
const key = `${file.path}|${hoster}`; const key = `${file.path}|${hoster}`;
const job = { const job = {
@@ -2545,7 +2606,6 @@ function buildQueuePreview() {
queueJobs.push(job); queueJobs.push(job);
existingKeys.add(key); existingKeys.add(key);
} }
if (availableSlots !== null) availableSlots -= eligibleHosters.length;
} }
} }
@@ -6973,7 +7033,7 @@ async function performSaveSettings(options = {}) {
const normalizedAutomationInputs = window.AutomationControl.normalizeAutomationSettings({ const normalizedAutomationInputs = window.AutomationControl.normalizeAutomationSettings({
...curFm, ...curFm,
queueLimitJobs: document.getElementById('fmQueueLimitInput')?.value ?? curFm.queueLimitJobs, queueLimitJobs: document.getElementById('fmQueueLimitInput')?.value ?? curFm.queueLimitJobs,
reconcileIntervalMinutes: document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes reconcileIntervalMinutes: Number(document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes)
}); });
const globalSettings = { const globalSettings = {
+7
View File
@@ -296,6 +296,13 @@
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'], ['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'], ['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'], ['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
['Automatik ist pausiert', 'Automation is paused'],
['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'],
['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'],
['Upload konnte nicht gestartet werden.', 'Upload could not be started.'],
['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'],
['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'],
['Upload-Start wurde verworfen', 'Upload start was discarded'],
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'], ['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
['Ordner nicht erreichbar', 'Folder unavailable'], ['Ordner nicht erreichbar', 'Folder unavailable'],
['Ordnerscan fehlgeschlagen', 'Folder scan failed'], ['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
+33 -1
View File
@@ -37,7 +37,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
pausedAt: 1700 pausedAt: 1700
}), { }), {
queueLimitJobs: 42, queueLimitJobs: 42,
reconcileIntervalMinutes: 15, reconcileIntervalMinutes: 5,
paused: false, paused: false,
pausedAt: null pausedAt: null
}); });
@@ -47,6 +47,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
paused: false, paused: false,
pausedAt: null pausedAt: null
}); });
assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15);
}); });
test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => { test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => {
@@ -238,6 +239,37 @@ test('telemetry deltas increment counters and update event details immutably', (
assert.equal(telemetry.lastError, 'old'); assert.equal(telemetry.lastError, 'old');
}); });
test('telemetry and deltas normalize every counter to a finite nonnegative integer', () => {
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
const telemetry = rollDailyTelemetry({
dateKey: '2026-08-26',
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -4,
deferred: 3.9
}, now);
assert.deepEqual({
detected: telemetry.detected,
queued: telemetry.queued,
skipped: telemetry.skipped,
deferred: telemetry.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 3 });
const changed = applyTelemetryDelta(telemetry, {
detected: Number.POSITIVE_INFINITY,
queued: Number.NaN,
skipped: -2,
deferred: 2.8
}, now);
assert.deepEqual({
detected: changed.detected,
queued: changed.queued,
skipped: changed.skipped,
deferred: changed.deferred
}, { detected: 0, queued: 0, skipped: 0, deferred: 5 });
assert.equal(Object.values(changed).filter(value => typeof value === 'number').every(value => Number.isFinite(value)), true);
});
test('pause has higher display priority than disconnect error and queue limit', () => { test('pause has higher display priority than disconnect error and queue limit', () => {
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused'); assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
}); });
+149 -20
View File
@@ -22,9 +22,11 @@ function createWatcherHarness() {
function createManualTimers() { function createManualTimers() {
const intervals = new Set(); const intervals = new Set();
const timeouts = new Set(); const timeouts = new Set();
const intervalDelays = [];
return { return {
setIntervalFn(callback) { setIntervalFn(callback, delay) {
intervals.add(callback); intervals.add(callback);
intervalDelays.push(delay);
return callback; return callback;
}, },
clearIntervalFn(callback) { clearIntervalFn(callback) {
@@ -45,7 +47,8 @@ function createManualTimers() {
timeouts.delete(callback); timeouts.delete(callback);
await callback(); await callback();
} }
} },
intervalDelays
}; };
} }
@@ -156,7 +159,7 @@ test('initial scan completion is exposed so the one-time option can be persisted
assert.equal(completed, 1); assert.equal(completed, 1);
}); });
test('dry scan returns matching descriptors without emitting new files', async () => { test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => {
const { monitor, events } = createScanHarness({ const { monitor, events } = createScanHarness({
files: [ files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }, { path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
@@ -165,10 +168,118 @@ test('dry scan returns matching descriptors without emitting new files', async (
}); });
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 }); monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 });
const result = await monitor.scan({ emitFiles: false, trigger: 'test' }); const result = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.deepEqual(result.files, [{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }]); assert.deepEqual(result.files, [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' }
]);
assert.equal(result.files.length, 2);
assert.equal(result.files.filter(file => file.filterMatched).length, 1);
assert.equal(events.newFiles.length, 0); assert.equal(events.newFiles.length, 0);
}); });
test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => {
const { monitor, events } = createScanHarness({
files: [
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
]
});
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 });
await monitor.scan({ emitFiles: true, trigger: 'startup' });
await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }],
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }]
]);
assert.equal(monitor.status().seenCount, 0);
});
test('reconciliation intervals accept only finite numeric positive-list values', () => {
const cases = [
[1, 60000],
[5, 300000],
[15, 900000],
[30, 1800000],
[60, 3600000],
[-1, 300000],
[Number.POSITIVE_INFINITY, 300000],
['1', 300000],
['15', 300000],
[2, 300000],
[undefined, 300000]
];
for (const [reconcileIntervalMinutes, expectedDelay] of cases) {
const timers = createManualTimers();
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes });
assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes));
monitor.stop();
}
});
test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => {
let now = 1000;
const timers = createManualTimers();
const monitor = new FolderMonitor({
watch: createSilentWatch(),
access: async () => {},
walkFolder: async () => [],
stat: async () => ({ mtimeMs: 1 }),
now: () => now,
...timers
});
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
now = 2000;
await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.equal(monitor.status().startedAt, 1000);
assert.equal(monitor.status().nextReconcileAt, 301000);
await monitor.pause();
assert.equal(monitor.status().startedAt, null);
assert.equal(monitor.status().nextReconcileAt, null);
now = 4000;
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().nextReconcileAt, 304000);
now = 304000;
await timers.runInterval();
assert.equal(monitor.status().startedAt, 4000);
assert.equal(monitor.status().lastScanAt, 304000);
assert.equal(monitor.status().nextReconcileAt, 604000);
});
test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => {
const timers = createManualTimers();
let watcherStarts = 0;
const monitor = new FolderMonitor({
watch: () => {
watcherStarts++;
return createSilentWatch()();
},
access: async () => {},
walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }],
stat: async () => ({ mtimeMs: 1 }),
...timers
});
const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
const scan = await monitor.scan({ emitFiles: false, trigger: 'test' });
const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' });
assert.deepEqual(result, { includesExisting: false, paused: true });
assert.equal(watcherStarts, 0);
assert.equal(timers.intervalDelays.length, 0);
assert.equal(monitor.status().paused, true);
assert.equal(monitor.status().folderPath, 'C:\\watch');
assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']);
assert.equal(productive.cancelled, true);
});
test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => { test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => {
const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness(); const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness();
const first = monitor.scan({ emitFiles: true, trigger: 'interval' }); const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
@@ -211,6 +322,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () =>
assert.equal(monitor.status().paused, false); assert.equal(monitor.status().paused, false);
}); });
test('resume can activate watcher and interval before a separately gated reconciliation', async () => {
const { monitor, scanCalls } = createReachabilityHarness(true);
const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 };
monitor.start(settings);
await monitor.pause();
const result = await monitor.resume(settings, { reconcile: false });
assert.equal(scanCalls(), 0);
assert.equal(result.reconciled, false);
assert.equal(monitor.status().running, true);
assert.equal(monitor.status().paused, false);
});
test('repeated pause emits one status change and keeps reconciliation stopped', async () => { test('repeated pause emits one status change and keeps reconciliation stopped', async () => {
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true); const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 }); monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
@@ -331,7 +456,7 @@ test('late watcher add callbacks are ignored after pause', async () => {
assert.deepEqual(newFiles, []); assert.deepEqual(newFiles, []);
}); });
test('resume preserves session duplicate history', async () => { test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => {
const timers = createManualTimers(); const timers = createManualTimers();
const watchers = []; const watchers = [];
const newFiles = []; const newFiles = [];
@@ -356,7 +481,10 @@ test('resume preserves session duplicate history', async () => {
await monitor.resume(settings); await monitor.resume(settings);
watchers[1].emit('add', 'C:\\watch\\same.mkv'); watchers[1].emit('add', 'C:\\watch\\same.mkv');
await timers.runTimeouts(); await timers.runTimeouts();
assert.deepEqual(newFiles, [['C:\\watch\\same.mkv']]); assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [
['C:\\watch\\same.mkv'],
['C:\\watch\\same.mkv']
]);
assert.equal(monitor.status().seenCount, 1); assert.equal(monitor.status().seenCount, 1);
}); });
@@ -385,11 +513,11 @@ test('watcher add paused before batch timeout is emitted exactly once by resume
await monitor.pause(); await monitor.pause();
assert.deepEqual(newFiles, []); assert.deepEqual(newFiles, []);
await monitor.resume(settings); await monitor.resume(settings);
assert.deepEqual(newFiles, [[filePath]]); assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]);
assert.equal(monitor.status().seenCount, 1); assert.equal(monitor.status().seenCount, 0);
}); });
test('synchronous pause during successful batch emission does not duplicate on resume', async () => { test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => {
const timers = createManualTimers(); const timers = createManualTimers();
const watchers = []; const watchers = [];
const newFiles = []; const newFiles = [];
@@ -418,7 +546,7 @@ test('synchronous pause during successful batch emission does not duplicate on r
await pausePromise; await pausePromise;
assert.deepEqual(newFiles, [[filePath]]); assert.deepEqual(newFiles, [[filePath]]);
await monitor.resume(settings); await monitor.resume(settings);
assert.deepEqual(newFiles, [[filePath]]); assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]);
assert.equal(monitor.status().seenCount, 1); assert.equal(monitor.status().seenCount, 1);
}); });
@@ -452,8 +580,9 @@ test('pause rollback never deletes historical seen state from a dedupe-off batch
watchers[1].emit('add', filePath); watchers[1].emit('add', filePath);
assert.deepEqual(newFiles, [[filePath]]); assert.deepEqual(newFiles, [[filePath]]);
await monitor.pause(); await monitor.pause();
discoverExisting = true;
await monitor.resume(dedupeOn); await monitor.resume(dedupeOn);
watchers[2].emit('add', filePath);
await timers.runTimeouts();
assert.deepEqual(newFiles, [[filePath]]); assert.deepEqual(newFiles, [[filePath]]);
assert.equal(monitor.status().seenCount, 1); assert.equal(monitor.status().seenCount, 1);
}); });
@@ -551,7 +680,7 @@ test('interval callback contains unexpected scan rejection', async () => {
assert.equal(statuses.at(-1).error.includes('interval-secret'), false); assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
}); });
test('real temporary folder converges through startup interval capacity recovery and one reconnect', async () => { test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
const detached = `${root}-detached`; const detached = `${root}-detached`;
const timers = createManualTimers(); const timers = createManualTimers();
@@ -569,12 +698,12 @@ test('real temporary folder converges through startup interval capacity recovery
fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z')); fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z'));
fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z')); fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers }); const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.on('new-files', (paths) => { monitor.on('new-files', (files) => {
const descriptors = paths.map(filePath => ({ const descriptors = files.map(file => typeof file === 'string' ? {
path: filePath, path: file,
name: path.basename(filePath), name: path.basename(file),
mtimeMs: fs.statSync(filePath).mtimeMs mtimeMs: fs.statSync(file).mtimeMs
})); } : file).filter(file => file.filterMatched !== false);
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] }); const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
const unprocessed = new Set(processed.unprocessedPaths); const unprocessed = new Set(processed.unprocessedPaths);
const candidates = descriptors const candidates = descriptors
@@ -595,12 +724,12 @@ test('real temporary folder converges through startup interval capacity recovery
recursive: true, recursive: true,
extensions: 'mkv', extensions: 'mkv',
filterMode: 'include', filterMode: 'include',
skipDuplicates: false, skipDuplicates: true,
reconcileIntervalMinutes: 5 reconcileIntervalMinutes: 5
}); });
const first = await monitor.scan({ emitFiles: true, trigger: 'startup' }); const first = await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.deepEqual(first.files.map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']); assert.deepEqual(first.files.filter(file => file.filterMatched).map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']);
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true); assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
assert.deepEqual(admissions[0], { assert.deepEqual(admissions[0], {
trigger: 'startup', trigger: 'startup',
+7
View File
@@ -107,6 +107,13 @@ test('translates every automation control center label in both directions', () =
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'], ['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'], ['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'], ['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
['Automatik ist pausiert', 'Automation is paused'],
['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'],
['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'],
['Upload konnte nicht gestartet werden.', 'Upload could not be started.'],
['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'],
['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'],
['Upload-Start wurde verworfen', 'Upload start was discarded'],
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'], ['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
['Ordner nicht erreichbar', 'Folder unavailable'], ['Ordner nicht erreichbar', 'Folder unavailable'],
['Ordnerscan fehlgeschlagen', 'Folder scan failed'], ['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
+101 -16
View File
@@ -45,6 +45,8 @@ function createAutomationLifecycleHarness(mainSource) {
const saves = []; const saves = [];
const pauseDeferred = createDeferred(); const pauseDeferred = createDeferred();
const resumeDeferred = createDeferred(); const resumeDeferred = createDeferred();
const configuredSettings = [];
const startedSettings = [];
let publishStatus = () => {}; let publishStatus = () => {};
let state = { let state = {
globalSettings: { globalSettings: {
@@ -60,9 +62,22 @@ function createAutomationLifecycleHarness(mainSource) {
}; };
const folderMonitor = new (require('node:events').EventEmitter)(); const folderMonitor = new (require('node:events').EventEmitter)();
folderMonitor.running = false; folderMonitor.running = false;
folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true }); folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true, startedAt: 100, nextReconcileAt: 200 });
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); publishStatus(); return {}; }; folderMonitor.configure = settings => {
configuredSettings.push(structuredClone(settings));
folderMonitor.running = false;
order.push('configure');
publishStatus();
return { includesExisting: false, paused: true };
};
folderMonitor.start = settings => {
startedSettings.push(structuredClone(settings));
folderMonitor.running = true;
order.push('start');
publishStatus();
return {};
};
folderMonitor.pause = () => { folderMonitor.pause = () => {
order.push('pause'); order.push('pause');
publishStatus(); publishStatus();
@@ -80,7 +95,10 @@ function createAutomationLifecycleHarness(mainSource) {
return { reachable: true }; return { reachable: true };
}); });
}; };
folderMonitor.scan = async () => ({ reachable: true }); folderMonitor.scan = async options => {
order.push(`scan:${options.trigger}:${options.emitFiles}`);
return { reachable: true, trigger: options.trigger };
};
const configStore = { const configStore = {
load: () => structuredClone(state), load: () => structuredClone(state),
save: config => { save: config => {
@@ -101,6 +119,7 @@ function createAutomationLifecycleHarness(mainSource) {
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
folderMonitor, folderMonitor,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
path, path,
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
uploadManager uploadManager
@@ -109,11 +128,17 @@ function createAutomationLifecycleHarness(mainSource) {
publishStatus = () => context.publishAutomationStatus(); publishStatus = () => context.publishAutomationStatus();
return { return {
handlers, handlers,
configuredSettings,
context,
order, order,
pauseDeferred, pauseDeferred,
resumeDeferred, resumeDeferred,
saves, saves,
sent, sent,
setFolderMonitorState(value) {
state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value };
},
startedSettings,
state: () => structuredClone(state), state: () => structuredClone(state),
publishStatus publishStatus
}; };
@@ -422,6 +447,7 @@ test('automation pause save commits before lifecycle effects and save failure is
folderMonitor.running = true; folderMonitor.running = true;
folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true }); folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true });
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
folderMonitor.configure = () => { folderMonitor.running = false; order.push('configure'); return { paused: true }; };
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; }; folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; };
folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); }; folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); };
folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; }; folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; };
@@ -458,6 +484,7 @@ test('automation pause save commits before lifecycle effects and save failure is
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
folderMonitor, folderMonitor,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
path, path,
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
uploadManager uploadManager
@@ -486,7 +513,7 @@ test('automation pause save commits before lifecycle effects and save failure is
state.globalSettings.folderMonitor.pausedAt = 1; state.globalSettings.folderMonitor.pausedAt = 1;
folderMonitor.running = false; folderMonitor.running = false;
await handlers.get('automation:resume')(); await handlers.get('automation:resume')();
assert.deepEqual(order, ['save:false', 'resume']); assert.deepEqual(order, ['resume', 'save:false', 'scan:resume:true']);
assert.equal(order.includes('startBatch'), false); assert.equal(order.includes('startBatch'), false);
assert.equal(sent.length, 1); assert.equal(sent.length, 1);
assert.equal(sent[0][1].paused, false); assert.equal(sent[0][1].paused, false);
@@ -504,15 +531,15 @@ test('automation lifecycle serializes pause then resume so the newer intent wins
harness.saves[0].deferred.resolve(); harness.saves[0].deferred.resolve();
await flushMicrotasks(); await flushMicrotasks();
harness.pauseDeferred.resolve(); harness.pauseDeferred.resolve();
await waitForCondition(() => harness.order.includes('resume'));
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 2); await waitForCondition(() => harness.saves.length === 2);
assert.equal(harness.saves.length, 2); assert.equal(harness.saves.length, 2);
assert.equal(harness.saves[1].paused, false); assert.equal(harness.saves[1].paused, false);
harness.saves[1].deferred.resolve(); harness.saves[1].deferred.resolve();
await flushMicrotasks();
harness.resumeDeferred.resolve();
await Promise.all([pause, resume]); await Promise.all([pause, resume]);
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'save:false', 'resume']); assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', 'scan:resume:true']);
assert.equal(harness.state().globalSettings.folderMonitor.paused, false); assert.equal(harness.state().globalSettings.folderMonitor.paused, false);
assert.equal(harness.sent.length, 1); assert.equal(harness.sent.length, 1);
assert.equal(harness.sent[0][1].paused, false); assert.equal(harness.sent[0][1].paused, false);
@@ -525,11 +552,12 @@ test('automation lifecycle serializes resume then pause so the newer intent wins
const resume = harness.handlers.get('automation:resume')(); const resume = harness.handlers.get('automation:resume')();
const pause = harness.handlers.get('automation:pause-after-active')(); const pause = harness.handlers.get('automation:pause-after-active')();
assert.equal(harness.saves.length, 1); assert.equal(harness.saves.length, 0);
assert.deepEqual(harness.order, ['resume']);
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 1);
assert.equal(harness.saves[0].paused, false); assert.equal(harness.saves[0].paused, false);
harness.saves[0].deferred.resolve(); harness.saves[0].deferred.resolve();
await flushMicrotasks();
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 2); await waitForCondition(() => harness.saves.length === 2);
assert.equal(harness.saves.length, 2); assert.equal(harness.saves.length, 2);
assert.equal(harness.saves[1].paused, true); assert.equal(harness.saves[1].paused, true);
@@ -538,7 +566,7 @@ test('automation lifecycle serializes resume then pause so the newer intent wins
harness.pauseDeferred.resolve(); harness.pauseDeferred.resolve();
await Promise.all([resume, pause]); await Promise.all([resume, pause]);
assert.deepEqual(harness.order, ['save:false', 'resume', 'save:true', 'pause', 'finish']); assert.deepEqual(harness.order, ['resume', 'save:false', 'scan:resume:true', 'save:true', 'pause', 'finish']);
assert.equal(harness.state().globalSettings.folderMonitor.paused, true); assert.equal(harness.state().globalSettings.folderMonitor.paused, true);
assert.equal(harness.sent.length, 1); assert.equal(harness.sent.length, 1);
assert.equal(harness.sent[0][1].paused, true); assert.equal(harness.sent[0][1].paused, true);
@@ -553,14 +581,14 @@ test('automation status suppression remains active until the serialized operatio
harness.saves[0].deferred.resolve(); harness.saves[0].deferred.resolve();
await waitForCondition(() => harness.order.includes('pause')); await waitForCondition(() => harness.order.includes('pause'));
harness.pauseDeferred.resolve(); harness.pauseDeferred.resolve();
await waitForCondition(() => harness.saves.length === 2); await waitForCondition(() => harness.order.includes('resume'));
harness.publishStatus(); harness.publishStatus();
assert.equal(harness.sent.length, 0); assert.equal(harness.sent.length, 0);
harness.saves[1].deferred.resolve();
await waitForCondition(() => harness.order.includes('resume'));
harness.resumeDeferred.resolve(); harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 2);
harness.saves[1].deferred.resolve();
await Promise.all([pause, resume]); await Promise.all([pause, resume]);
assert.equal(harness.sent.length, 1); assert.equal(harness.sent.length, 1);
@@ -585,6 +613,62 @@ test('automation pause rejection still finishes active uploads and returns a san
assert.equal(JSON.stringify(harness.sent[0][1]).includes('secret-value'), false); assert.equal(JSON.stringify(harness.sent[0][1]).includes('secret-value'), false);
}); });
test('every folder monitor start obeys current persisted pause and active activation reconciles exactly once', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
const settings = { folderPath: 'C:\\watch', enabled: true, paused: false, reconcileIntervalMinutes: '1' };
const pausedResult = await harness.handlers.get('folder-monitor:start')(null, settings);
assert.deepEqual({ ...pausedResult }, { error: 'Automatik ist pausiert' });
assert.deepEqual(harness.order, ['configure']);
assert.equal(harness.configuredSettings[0].reconcileIntervalMinutes, 5);
harness.order.length = 0;
harness.setFolderMonitorState({ paused: false, pausedAt: null, reconcileIntervalMinutes: '1' });
const activeResult = await harness.handlers.get('folder-monitor:start')(null, settings);
assert.deepEqual({ ...activeResult }, { ok: true, includesExisting: false });
assert.deepEqual(harness.order, ['start', 'scan:startup:true']);
assert.equal(harness.startedSettings[0].reconcileIntervalMinutes, 5);
const status = harness.handlers.get('automation:get-status')();
assert.equal(status.reconcileIntervalMinutes, 5);
assert.equal(status.startedAt, 100);
assert.equal(status.nextReconcileAt, 200);
});
test('resume keeps pause authoritative until monitor success and restores the previous pause after rejection', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
let settled = false;
const resume = harness.handlers.get('automation:resume')();
const outcome = resume.then(value => ({ value }), error => ({ error })).finally(() => { settled = true; });
await flushMicrotasks();
const pendingState = harness.state().globalSettings.folderMonitor;
const savesBeforeResolution = harness.saves.length;
const orderBeforeResolution = [...harness.order];
if (harness.saves[0]) harness.saves[0].deferred.resolve();
await waitForCondition(() => harness.order.includes('resume'));
harness.resumeDeferred.reject(new Error('token=resume-secret'));
for (let attempt = 0; attempt < 50 && !settled; attempt++) {
for (const save of harness.saves) save.deferred.resolve();
await Promise.resolve();
}
const result = await outcome;
assert.equal(savesBeforeResolution, 0);
assert.deepEqual(orderBeforeResolution, ['resume']);
assert.equal(pendingState.paused, true);
assert.equal(pendingState.pausedAt, 1);
assert.equal(result.error, undefined);
assert.equal(result.value.error, 'Automatik konnte nicht fortgesetzt werden');
assert.equal(result.value.paused, true);
assert.equal(result.value.pausedAt, 1);
assert.deepEqual(harness.saves.map(save => save.paused), [true]);
assert.deepEqual(harness.order, ['resume', 'stop', 'configure', 'save:true']);
assert.equal(harness.state().globalSettings.folderMonitor.paused, true);
assert.equal(harness.state().globalSettings.folderMonitor.pausedAt, 1);
assert.equal(JSON.stringify(result.value).includes('resume-secret'), false);
});
test('prepared upload start waits for the final tick and clears recovery when pause wins', async () => { test('prepared upload start waits for the final tick and clears recovery when pause wins', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart'); const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart');
@@ -728,7 +812,8 @@ test('startup keeps a missing configured folder disconnected without disabling a
assert.notEqual(startupEnd, -1); assert.notEqual(startupEnd, -1);
const startup = mainSource.slice(startupStart, startupEnd); const startup = mainSource.slice(startupStart, startupEnd);
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u); assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/u);
assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); assert.doesNotMatch(startup, /fm\.paused\s*!==\s*true/u);
assert.doesNotMatch(startup, /folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u);
assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u); assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u);
}); });
+183 -18
View File
@@ -81,6 +81,9 @@ let automationProbe = {
saveSettingsError: '', saveSettingsError: '',
testScanError: '', testScanError: '',
deferTestScan: false, deferTestScan: false,
deferInspect: false,
activeInspections: 0,
maxConcurrentInspections: 0,
dryScan: { files: [], reachable: true, trigger: 'test' }, dryScan: { files: [], reachable: true, trigger: 'test' },
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
mutationCalls: [], mutationCalls: [],
@@ -184,6 +187,9 @@ contextBridge.exposeInMainWorld('api', {
saveSettingsError: String(value.saveSettingsError || ''), saveSettingsError: String(value.saveSettingsError || ''),
testScanError: String(value.testScanError || ''), testScanError: String(value.testScanError || ''),
deferTestScan: value.deferTestScan === true, deferTestScan: value.deferTestScan === true,
deferInspect: value.deferInspect === true,
activeInspections: 0,
maxConcurrentInspections: 0,
dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' }, dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' },
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
mutationCalls: [], mutationCalls: [],
@@ -194,13 +200,17 @@ contextBridge.exposeInMainWorld('api', {
getAutomationProbeState() { getAutomationProbeState() {
return { return {
readCalls: { ...automationProbe.readCalls }, readCalls: { ...automationProbe.readCalls },
activeInspections: automationProbe.activeInspections,
maxConcurrentInspections: automationProbe.maxConcurrentInspections,
mutationCalls: automationProbe.mutationCalls.map(value => [...value]), mutationCalls: automationProbe.mutationCalls.map(value => [...value]),
logs: [...automationProbe.logs], logs: [...automationProbe.logs],
savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value))) savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value)))
}; };
}, },
inspectImportFiles(entries, existingPaths) { async inspectImportFiles(entries, existingPaths) {
automationProbe.readCalls.inspect++; automationProbe.readCalls.inspect++;
automationProbe.activeInspections++;
automationProbe.maxConcurrentInspections = Math.max(automationProbe.maxConcurrentInspections, automationProbe.activeInspections);
const candidates = Array.isArray(entries) ? entries : []; const candidates = Array.isArray(entries) ? entries : [];
const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase(); const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase();
const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize)); const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize));
@@ -216,7 +226,9 @@ contextBridge.exposeInMainWorld('api', {
} }
const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' })); const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' }));
const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry })); const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry }));
return Promise.resolve({ if (automationProbe.deferInspect) await new Promise(resolve => setTimeout(resolve, 5));
automationProbe.activeInspections--;
return {
candidateCount: candidates.length, candidateCount: candidates.length,
duplicateCount: duplicates.length, duplicateCount: duplicates.length,
unavailableCount: unavailable.length, unavailableCount: unavailable.length,
@@ -224,7 +236,7 @@ contextBridge.exposeInMainWorld('api', {
accepted, accepted,
duplicates, duplicates,
unavailable unavailable
}); };
}, },
getHistory() { getHistory() {
automationProbe.readCalls.history++; automationProbe.readCalls.history++;
@@ -805,13 +817,76 @@ contextBridge.exposeInMainWorld('api', {
handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }]) handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }])
]); ]);
const parallelAdmission = { const parallelAdmission = {
admittedFiles: parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)), admittedFiles: [...new Set(parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)))],
matchingJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)).length, matchingJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)).length,
matchingPaths: [...new Set(queueJobs matchingPaths: [...new Set(queueJobs
.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)) .filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path))
.map(job => normalizeAutomationPath(job.file)))], .map(job => normalizeAutomationPath(job.file)))],
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued
}; };
configureAtomicState(18);
config.globalSettings.folderMonitor.queueLimitJobs = 20;
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
config.globalSettings.folderMonitor.autoStart = false;
hosterSettings = {};
const distinctFiles = Array.from({ length: 20 }, (_, index) => ({
path: 'C:\\\\distinct\\\\distinct-' + String(index).padStart(3, '0') + '.mkv',
name: 'distinct-' + String(index).padStart(3, '0') + '.mkv',
size: 1,
mtimeMs: index
}));
window.api.configureAutomationProbe({ paused: false, deferInspect: true });
await Promise.all(distinctFiles.map(file => handleFolderMonitorFiles([file])));
const distinctProbe = await window.api.getAutomationProbeState();
const distinctTelemetry = config.globalSettings.folderMonitor.telemetry;
const distinctParallel = {
inspectCalls: distinctProbe.readCalls.inspect,
maxConcurrentInspections: distinctProbe.maxConcurrentInspections,
capacityJobs: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
distinctJobs: queueJobs.filter(job => job.file.startsWith('C:\\\\distinct\\\\')).length,
detected: distinctTelemetry.detected,
queued: distinctTelemetry.queued,
deferred: distinctTelemetry.deferred,
lastDetectedName: distinctTelemetry.lastDetectedName
};
configureAtomicState(14999);
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
config.globalSettings.folderMonitor.autoStart = false;
hosterSettings = { 'doodstream.com': { maxSizeMb: 2 } };
const reasonCandidates = [
{ path: 'C:\\\\reasons\\\\admitted.mkv', name: 'admitted.mkv', size: 1, mtimeMs: 1, filterMatched: true },
{ path: 'C:\\\\reasons\\\\deferred.mkv', name: 'deferred.mkv', size: 1, mtimeMs: 2, filterMatched: true },
{ path: 'C:\\\\reasons\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 3, filterMatched: false },
{ path: 'C:\\\\reasons\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true },
{ path: 'C:\\\\reasons\\\\inspection-duplicate.mkv', name: 'inspection-duplicate.mkv', size: 1, mtimeMs: 5, filterMatched: true },
{ path: 'C:\\\\reasons\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 6, filterMatched: true, unavailable: true },
{ path: 'C:\\\\reasons\\\\size-limited.mkv', name: 'size-limited.mkv', size: 3 * 1024 * 1024, mtimeMs: 7, filterMatched: true }
];
_pendingFiles = [reasonCandidates[4]];
window.api.configureAutomationProbe({
paused: false,
history: [{ files: [{ path: reasonCandidates[3].path, name: reasonCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
});
const reasonEvaluation = await evaluateAutomationCandidates(reasonCandidates, { dryRun: false, trigger: 'watcher' });
const reasonResult = await applyAutomationEvaluation(reasonEvaluation);
const reasonCounts = {};
for (const entry of reasonEvaluation.classifications || []) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1;
const disjointClassification = {
summary: reasonEvaluation.summary,
reasonCounts,
classificationCount: reasonEvaluation.classifications?.length || 0,
telemetryDelta: reasonEvaluation.telemetryDelta,
applied: {
admitted: reasonResult.admittedFiles.map(file => file.name),
deferred: reasonResult.deferredFiles.map(file => file.name)
},
telemetry: {
detected: config.globalSettings.folderMonitor.telemetry.detected,
queued: config.globalSettings.folderMonitor.telemetry.queued,
skipped: config.globalSettings.folderMonitor.telemetry.skipped,
deferred: config.globalSettings.folderMonitor.telemetry.deferred
}
};
const atomicCandidates = [ const atomicCandidates = [
{ path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true }, { path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true },
{ path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true } { path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true }
@@ -833,6 +908,8 @@ contextBridge.exposeInMainWorld('api', {
queued: config.globalSettings.folderMonitor.telemetry.queued, queued: config.globalSettings.folderMonitor.telemetry.queued,
currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs), currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length, unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length,
manualJobHosters: queueJobs.filter(job => job.fileName === 'unplanned.mkv').map(job => job.hoster),
automationJobHosters: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort(),
selectedHostersAfterApply, selectedHostersAfterApply,
manualSelectionFilesAfterApply, manualSelectionFilesAfterApply,
plannedHostsBeforeRebuild, plannedHostsBeforeRebuild,
@@ -1802,7 +1879,7 @@ contextBridge.exposeInMainWorld('api', {
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
}; };
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
})()`; })()`;
const automationControlCenterScript = `(async () => { const automationControlCenterScript = `(async () => {
const waitFor = async predicate => { const waitFor = async predicate => {
@@ -1820,6 +1897,7 @@ contextBridge.exposeInMainWorld('api', {
folderPath: 'C:\\\\watch', folderPath: 'C:\\\\watch',
lastScanAt: fixedNow - 60000, lastScanAt: fixedNow - 60000,
startedAt: fixedNow - 3600000, startedAt: fixedNow - 3600000,
nextReconcileAt: fixedNow + 123456,
error: '' error: ''
}; };
setUiLanguage('de'); setUiLanguage('de');
@@ -1882,8 +1960,17 @@ contextBridge.exposeInMainWorld('api', {
queueLimitMin: queueLimitInput?.min || null, queueLimitMin: queueLimitInput?.min || null,
intervalDefault: intervalInput?.value || null, intervalDefault: intervalInput?.value || null,
intervalOptions: [...(intervalInput?.options || [])].map(option => option.value), intervalOptions: [...(intervalInput?.options || [])].map(option => option.value),
snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry) snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry),
startedAt: createAutomationStatusSnapshot().startedAt,
nextReconcileAt: createAutomationStatusSnapshot().nextReconcileAt
}; };
applyAutomationRuntimeStatus({ ...runtimeStatus, startedAt: null, nextReconcileAt: null });
const missingMainTimes = createAutomationStatusSnapshot();
const noRendererTimeEstimate = {
startedAt: missingMainTimes.startedAt,
nextReconcileAt: missingMainTimes.nextReconcileAt
};
applyAutomationRuntimeStatus(runtimeStatus);
if (queueLimitInput) { if (queueLimitInput) {
queueLimitInput.value = '0'; queueLimitInput.value = '0';
queueLimitInput.dispatchEvent(new Event('input', { bubbles: true })); queueLimitInput.dispatchEvent(new Event('input', { bubbles: true }));
@@ -1908,6 +1995,24 @@ contextBridge.exposeInMainWorld('api', {
renderAutomationStatusSnapshot(baseSnapshot); renderAutomationStatusSnapshot(baseSnapshot);
} }
const originalSnapshotFactory = createAutomationStatusSnapshot; const originalSnapshotFactory = createAutomationStatusSnapshot;
const finiteQueueSnapshot = originalSnapshotFactory();
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, queueLimitJobs: 0, availableSlots: null }));
const unlimitedQueueAria = {
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
};
renderAutomationStatusSnapshot(finiteQueueSnapshot);
const finiteQueueAria = {
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
};
setUiLanguage('en');
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' }));
const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || '';
setUiLanguage('de');
renderAutomationStatusSnapshot(finiteQueueSnapshot);
let snapshotCalls = 0; let snapshotCalls = 0;
const pausedSnapshot = Object.freeze({ const pausedSnapshot = Object.freeze({
...originalSnapshotFactory(), ...originalSnapshotFactory(),
@@ -2089,7 +2194,7 @@ contextBridge.exposeInMainWorld('api', {
enabledAfterCancel, enabledAfterCancel,
lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none' lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none'
}; };
return { initial, states, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading };
})()`; })()`;
const automationControlCenterLayoutScript = `(() => { const automationControlCenterLayoutScript = `(() => {
const card = document.getElementById('automationStatusCard'); const card = document.getElementById('automationStatusCard');
@@ -2545,6 +2650,49 @@ app.whenReady().then(async () => {
matchingPaths: ['c:/watch/parallel.mkv'], matchingPaths: ['c:/watch/parallel.mkv'],
queuedTelemetry: 1 queuedTelemetry: 1
}); });
assert.deepEqual(result.automationPipeline.distinctParallel, {
inspectCalls: 3,
maxConcurrentInspections: 1,
capacityJobs: 20,
distinctJobs: 2,
detected: 20,
queued: 2,
deferred: 18,
lastDetectedName: 'distinct-019.mkv'
});
assert.deepEqual(result.automationPipeline.disjointClassification, {
summary: {
found: 7,
filterMatched: 6,
alreadyProcessed: 2,
unavailable: 1,
sizeLimitedJobs: 1,
acceptedFiles: 2,
selectedTargets: 1,
resultingJobs: 2,
availableSlots: 1,
deferredFiles: 1
},
reasonCounts: {
admitted: 1,
deferred: 1,
'filter-rejected': 1,
processed: 1,
'inspection-duplicate': 1,
unavailable: 1,
'size-limited': 1
},
classificationCount: 7,
telemetryDelta: {
detected: 7,
queued: 1,
skipped: 5,
deferred: 1,
lastDetectedName: 'size-limited.mkv'
},
applied: { admitted: ['admitted.mkv'], deferred: ['deferred.mkv'] },
telemetry: { detected: 7, queued: 1, skipped: 5, deferred: 1 }
});
assert.deepEqual(result.automationPipeline.manualHostTransactional, { assert.deepEqual(result.automationPipeline.manualHostTransactional, {
readFailure: { readFailure: {
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' }, result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
@@ -2569,8 +2717,10 @@ app.whenReady().then(async () => {
admittedFiles: ['b.mkv'], admittedFiles: ['b.mkv'],
deferred: 1, deferred: 1,
queued: 1, queued: 1,
currentJobCount: 15000, currentJobCount: 15001,
unplannedJobs: 0, unplannedJobs: 1,
manualJobHosters: ['clouddrop.cc'],
automationJobHosters: ['byse.sx', 'vidmoly.me'],
selectedHostersAfterApply: ['clouddrop.cc'], selectedHostersAfterApply: ['clouddrop.cc'],
manualSelectionFilesAfterApply: ['unplanned.mkv'], manualSelectionFilesAfterApply: ['unplanned.mkv'],
plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'], plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'],
@@ -2578,7 +2728,7 @@ app.whenReady().then(async () => {
}); });
assert.deepEqual(result.automationPipeline.status, { assert.deepEqual(result.automationPipeline.status, {
state: 'queue-limited', state: 'queue-limited',
currentJobCount: 15000, currentJobCount: 15001,
availableSlots: 0, availableSlots: 0,
queueLimited: true, queueLimited: true,
frozen: true frozen: true
@@ -2842,15 +2992,15 @@ app.whenReady().then(async () => {
assert.deepEqual(result.automationPipeline.fulfilledFeedback, { assert.deepEqual(result.automationPipeline.fulfilledFeedback, {
watcherWarning: { watcherWarning: {
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
feedback: ['Telemetrie konnte nicht gespeichert werden.'] feedback: ['Telemetry could not be saved.']
}, },
watcherError: { watcherError: {
result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' }, result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' },
feedback: ['Jobs konnten nicht hinzugefügt werden.'] feedback: ['Jobs could not be added.']
}, },
modalWarning: { modalWarning: {
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
feedback: ['Telemetrie konnte nicht gespeichert werden.'], feedback: ['Telemetry could not be saved.'],
pending: 0, pending: 0,
markers: 0, markers: 0,
modalOpen: false, modalOpen: false,
@@ -2919,6 +3069,9 @@ app.whenReady().then(async () => {
assert.equal(result.automationControlCenter.initial.intervalDefault, '5'); assert.equal(result.automationControlCenter.initial.intervalDefault, '5');
assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']); assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']);
assert.equal(result.automationControlCenter.initial.snapshotFrozen, true); assert.equal(result.automationControlCenter.initial.snapshotFrozen, true);
assert.equal(result.automationControlCenter.initial.startedAt, 1787709000000);
assert.equal(result.automationControlCenter.initial.nextReconcileAt, 1787712723456);
assert.deepEqual(result.automationControlCenter.noRendererTimeEstimate, { startedAt: null, nextReconcileAt: null });
assert.deepEqual(result.automationControlCenter.states, [ assert.deepEqual(result.automationControlCenter.states, [
{ state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true }, { state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true },
{ state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true }, { state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true },
@@ -2927,6 +3080,9 @@ app.whenReady().then(async () => {
{ state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true }, { state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true },
{ state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true } { state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true }
]); ]);
assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' });
assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' });
assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed');
assert.deepEqual(result.automationControlCenter.pausedControls, { assert.deepEqual(result.automationControlCenter.pausedControls, {
snapshotCalls: 1, snapshotCalls: 1,
pauseButtonDisabled: false, pauseButtonDisabled: false,
@@ -3393,6 +3549,7 @@ test('persisted automation pause survives runtime restart and resumes one reconc
const { contextBridge, ipcRenderer } = require('electron'); const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('restartProbe', { contextBridge.exposeInMainWorld('restartProbe', {
status: () => ipcRenderer.invoke('automation:get-status'), status: () => ipcRenderer.invoke('automation:get-status'),
testScan: () => ipcRenderer.invoke('folder-monitor:test-scan'),
startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings), startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings),
reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'), reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }), start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }),
@@ -3421,6 +3578,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
status: 'preview' status: 'preview'
}; };
const initial = await window.restartProbe.status(); const initial = await window.restartProbe.status();
const testScan = await window.restartProbe.testScan();
const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' }); const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' });
const reconcile = await captureFailure(() => window.restartProbe.reconcile()); const reconcile = await captureFailure(() => window.restartProbe.reconcile());
const start = await window.restartProbe.start(preview); const start = await window.restartProbe.start(preview);
@@ -3443,6 +3601,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
const final = await window.restartProbe.counters(); const final = await window.restartProbe.counters();
window.__automationRestartResult = { window.__automationRestartResult = {
initial, initial,
testScan,
monitorStart, monitorStart,
reconcile, reconcile,
start, start,
@@ -3467,6 +3626,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))}); const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
const FolderMonitor = require(${JSON.stringify(path.join(projectRoot, 'lib', 'folder-monitor.js'))}); const FolderMonitor = require(${JSON.stringify(path.join(projectRoot, 'lib', 'folder-monitor.js'))});
const { normalizeAutomationSettings } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'automation-control.js'))});
const outputPath = process.env.MHU_AUTOMATION_OUTPUT; const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
const rendererPath = process.env.MHU_AUTOMATION_RENDERER; const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD; const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
@@ -3650,18 +3810,23 @@ ${startupAutomation}
}); });
assert.equal(outcome.result.error, undefined); assert.equal(outcome.result.error, undefined);
assert.equal(outcome.result.initial.paused, true); assert.equal(outcome.result.initial.paused, true);
assert.equal(outcome.result.testScan.reachable, true);
assert.equal(outcome.result.testScan.trigger, 'test');
assert.equal(outcome.result.testScan.files.length, 1);
assert.equal(outcome.result.testScan.files[0].name, 'manual-preview.mkv');
assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' }); assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' });
assert.equal(outcome.result.reconcile.ok, false); assert.equal(outcome.result.reconcile.ok, true);
assert.deepEqual(outcome.result.reconcile.value, { error: 'Automatik ist pausiert' });
assert.deepEqual(outcome.result.start, { error: 'Automatik ist pausiert' }); assert.deepEqual(outcome.result.start, { error: 'Automatik ist pausiert' });
assert.deepEqual(outcome.result.extend, { error: 'Automatik ist pausiert' }); assert.deepEqual(outcome.result.extend, { error: 'Automatik ist pausiert' });
assert.equal(outcome.result.previewStatus, 'preview'); assert.equal(outcome.result.previewStatus, 'preview');
assert.equal(outcome.result.beforeResume.watcherStarts, 0); assert.equal(outcome.result.beforeResume.watcherStarts, 0);
assert.equal(outcome.result.beforeResume.intervalCount, 0); assert.equal(outcome.result.beforeResume.intervalCount, 0);
assert.equal(outcome.result.beforeResume.walkCalls, 0); assert.equal(outcome.result.beforeResume.walkCalls, 1);
assert.equal(outcome.result.resume.paused, false); assert.equal(outcome.result.resume.paused, false);
assert.equal(outcome.result.afterResume.watcherStarts, 1); assert.equal(outcome.result.afterResume.watcherStarts, 1);
assert.equal(outcome.result.afterResume.intervalCount, 1); assert.equal(outcome.result.afterResume.intervalCount, 1);
assert.equal(outcome.result.afterResume.walkCalls, 1); assert.equal(outcome.result.afterResume.walkCalls, 2);
assert.equal(outcome.result.afterResume.newFileEvents, 1); assert.equal(outcome.result.afterResume.newFileEvents, 1);
assert.equal(outcome.result.afterResume.addJobsCalls, 0); assert.equal(outcome.result.afterResume.addJobsCalls, 0);
assert.equal(outcome.result.afterResume.startBatchCalls, 0); assert.equal(outcome.result.afterResume.startBatchCalls, 0);
@@ -3673,7 +3838,7 @@ ${startupAutomation}
assert.equal(outcome.result.final.finishCalls, 1); assert.equal(outcome.result.final.finishCalls, 1);
assert.equal(outcome.result.final.watcherCloseCalls, 1); assert.equal(outcome.result.final.watcherCloseCalls, 1);
assert.equal(outcome.result.final.intervalCount, 0); assert.equal(outcome.result.final.intervalCount, 0);
assert.equal(outcome.result.final.walkCalls, 1); assert.equal(outcome.result.final.walkCalls, 2);
assert.equal(outcome.result.final.configPaused, true); assert.equal(outcome.result.final.configPaused, true);
assert.equal(outcome.result.final.monitor.paused, true); assert.equal(outcome.result.final.monitor.paused, true);
assert.equal(outcome.result.final.monitor.running, false); assert.equal(outcome.result.final.monitor.running, false);
@@ -3685,6 +3850,7 @@ ${startupAutomation}
test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => { test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => {
const projectRoot = path.join(__dirname, '..'); const projectRoot = path.join(__dirname, '..');
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-')); const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));
try {
const appRoot = path.join(probeRoot, 'app'); const appRoot = path.join(probeRoot, 'app');
const userDataPath = path.join(probeRoot, 'user-data'); const userDataPath = path.join(probeRoot, 'user-data');
const outputPath = path.join(probeRoot, 'result.json'); const outputPath = path.join(probeRoot, 'result.json');
@@ -3820,7 +3986,6 @@ async function waitFor(read, timeoutMs = 20000) {
}); });
`; `;
fs.writeFileSync(probePath, probeSource, 'utf8'); fs.writeFileSync(probePath, probeSource, 'utf8');
try {
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
const probeEnvironment = { const probeEnvironment = {
...process.env, ...process.env,
+38 -1
View File
@@ -7,6 +7,7 @@ const path = require('path');
const { const {
createInternalLogPathResolver, createInternalLogPathResolver,
createInternalLogWriter, createInternalLogWriter,
createBufferedInternalLogFlusher,
createUploadAuditWriter, createUploadAuditWriter,
getLogOpenDirectory getLogOpenDirectory
} = require('../lib/upload-audit'); } = require('../lib/upload-audit');
@@ -128,10 +129,46 @@ test('synchronous rotation flush falls back completely and retains buffered line
test('quit flush delegates the rotation buffer to the synchronous internal writer', () => { test('quit flush delegates the rotation buffer to the synchronous internal writer', () => {
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
assert.match(mainSource, /_rotLogWriter\.flushSync\(_rotLogBuffer, 'rot-log'\);/); assert.match(mainSource, /_rotLogFlusher\.flushSync\('rot-log'\);/);
assert.doesNotMatch(mainSource, /appendFileSync\(getRotLogPath\(\), _rotLogBuffer\.join\(''\)/); assert.doesNotMatch(mainSource, /appendFileSync\(getRotLogPath\(\), _rotLogBuffer\.join\(''\)/);
}); });
test('asynchronous rotation flush restores a failed chunk ahead of newer lines without a retry loop', async () => {
const buffer = ['first\n', 'second\n'];
const scheduled = [];
let resolveAppend;
const appendCalls = [];
const syncCalls = [];
const writer = {
append(value) {
appendCalls.push(value);
return new Promise(resolve => { resolveAppend = resolve; });
},
flushSync(lines) {
syncCalls.push([...lines]);
lines.length = 0;
return true;
}
};
const flusher = createBufferedInternalLogFlusher({
buffer,
writer,
schedule: callback => scheduled.push(callback)
});
const failed = flusher.flush('rot-log');
assert.deepEqual(buffer, []);
buffer.push('third\n');
resolveAppend(false);
assert.equal(await failed, false);
assert.deepEqual(buffer, ['first\n', 'second\n', 'third\n']);
assert.deepEqual(appendCalls, ['first\nsecond\n']);
assert.deepEqual(scheduled, []);
assert.equal(flusher.flushSync('rot-log'), true);
assert.deepEqual(syncCalls, [['first\n', 'second\n', 'third\n']]);
assert.deepEqual(buffer, []);
});
test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => { test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => {
const directory = createTempDirectory(t, 'mhu-upload-log-contract-'); const directory = createTempDirectory(t, 'mhu-upload-log-contract-');
const userDataPath = path.join(directory, 'user-data'); const userDataPath = path.join(directory, 'user-data');