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:
@@ -25,13 +25,22 @@
|
||||
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 = {}) {
|
||||
const settings = asObject(value);
|
||||
const queueLimitJobs = normalizeQueueLimit(settings.queueLimitJobs);
|
||||
const rawInterval = Number(settings.reconcileIntervalMinutes);
|
||||
return {
|
||||
queueLimitJobs,
|
||||
reconcileIntervalMinutes: allowedIntervals.has(rawInterval) ? rawInterval : 5,
|
||||
reconcileIntervalMinutes: normalizeReconcileInterval(settings.reconcileIntervalMinutes),
|
||||
paused: settings.paused === true,
|
||||
pausedAt: settings.paused === true && Number.isFinite(Number(settings.pausedAt)) ? Number(settings.pausedAt) : null
|
||||
};
|
||||
@@ -100,10 +109,10 @@
|
||||
...emptyTelemetry(dateKey),
|
||||
...telemetry,
|
||||
dateKey,
|
||||
detected: Math.max(0, Number(telemetry.detected) || 0),
|
||||
queued: Math.max(0, Number(telemetry.queued) || 0),
|
||||
skipped: Math.max(0, Number(telemetry.skipped) || 0),
|
||||
deferred: Math.max(0, Number(telemetry.deferred) || 0)
|
||||
detected: normalizeCounter(telemetry.detected),
|
||||
queued: normalizeCounter(telemetry.queued),
|
||||
skipped: normalizeCounter(telemetry.skipped),
|
||||
deferred: normalizeCounter(telemetry.deferred)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +120,7 @@
|
||||
const changes = asObject(delta);
|
||||
const next = rollDailyTelemetry(value, nowMs);
|
||||
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) {
|
||||
next.lastDetectedName = String(changes.lastDetectedName);
|
||||
|
||||
+42
-9
@@ -3,6 +3,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const { walkFolderAsync } = require('./file-discovery');
|
||||
const { normalizeAutomationSettings } = require('./automation-control');
|
||||
|
||||
class FolderMonitor extends EventEmitter {
|
||||
constructor({
|
||||
@@ -44,6 +45,9 @@ class FolderMonitor extends EventEmitter {
|
||||
this._lastScanAt = null;
|
||||
this._lastScanTrigger = '';
|
||||
this._lastError = '';
|
||||
this._startedAt = null;
|
||||
this._nextReconcileAt = null;
|
||||
this._reconcileIntervalMs = 5 * 60 * 1000;
|
||||
this._generation = 0;
|
||||
}
|
||||
|
||||
@@ -55,6 +59,24 @@ class FolderMonitor extends EventEmitter {
|
||||
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() {
|
||||
this._deactivate({ clearSeen: true, paused: false });
|
||||
}
|
||||
@@ -67,7 +89,9 @@ class FolderMonitor extends EventEmitter {
|
||||
scanning: this._scanning,
|
||||
folderPath: this._settings ? this._settings.folderPath : '',
|
||||
seenCount: this._seenFiles.size,
|
||||
startedAt: this._startedAt,
|
||||
lastScanAt: this._lastScanAt,
|
||||
nextReconcileAt: this._nextReconcileAt,
|
||||
lastScanTrigger: this._lastScanTrigger,
|
||||
error: this._lastError
|
||||
});
|
||||
@@ -106,19 +130,22 @@ class FolderMonitor extends EventEmitter {
|
||||
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');
|
||||
this._start(settings, true);
|
||||
if (options.reconcile === false) return { reconciled: false };
|
||||
return this.scan({ emitFiles: true, trigger: 'resume' });
|
||||
}
|
||||
|
||||
_start(settings, preserveSeen) {
|
||||
this._deactivate({ clearSeen: !preserveSeen, paused: false });
|
||||
this._settings = settings;
|
||||
const reconcileIntervalMinutes = normalizeAutomationSettings(settings).reconcileIntervalMinutes;
|
||||
this._settings = { ...settings, reconcileIntervalMinutes };
|
||||
this._paused = false;
|
||||
this._reachable = null;
|
||||
this._lastError = '';
|
||||
|
||||
settings = this._settings;
|
||||
const folderPath = String(settings.folderPath || '').trim();
|
||||
if (!folderPath) throw new Error('Kein Ordnerpfad angegeben');
|
||||
|
||||
@@ -158,10 +185,12 @@ class FolderMonitor extends EventEmitter {
|
||||
this._emitStatus(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._reconcile(generation).catch((error) => this._publishBackgroundError(error, generation)),
|
||||
intervalMinutes * 60 * 1000
|
||||
this._reconcileIntervalMs
|
||||
);
|
||||
this._emitStatus(generation);
|
||||
return { includesExisting: includeInitial };
|
||||
@@ -194,6 +223,8 @@ class FolderMonitor extends EventEmitter {
|
||||
}
|
||||
this._batchSeenReservations.clear();
|
||||
this._paused = paused;
|
||||
this._startedAt = null;
|
||||
this._nextReconcileAt = null;
|
||||
this._scanning = false;
|
||||
this._followUpRequested = false;
|
||||
this._followUpOptions = null;
|
||||
@@ -280,9 +311,8 @@ class FolderMonitor extends EventEmitter {
|
||||
try {
|
||||
const files = await this._discoverFiles(settings, generation);
|
||||
if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
|
||||
const emittedFiles = files.filter((file) => this._acceptPath(file.path));
|
||||
if (emittedFiles.length > 0) {
|
||||
const listenerError = this._emitEvent('new-files', [emittedFiles.map((file) => file.path)], generation);
|
||||
if (files.length > 0) {
|
||||
const listenerError = this._emitEvent('new-files', [files], generation);
|
||||
if (listenerError) return this._finishProductiveError(listenerError, generation, trigger, true);
|
||||
}
|
||||
if (!this._isCurrent(generation)) return this._cancelledResult(trigger);
|
||||
@@ -325,7 +355,7 @@ class FolderMonitor extends EventEmitter {
|
||||
for (const descriptor of discovered) {
|
||||
if (!this._isCurrent(generation)) return [];
|
||||
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;
|
||||
try {
|
||||
mtimeMs = Number((await this._stat(descriptor.path)).mtimeMs) || 0;
|
||||
@@ -335,7 +365,9 @@ class FolderMonitor extends EventEmitter {
|
||||
path: descriptor.path,
|
||||
name: descriptor.name || path.basename(descriptor.path),
|
||||
size: Number(descriptor.size) || 0,
|
||||
mtimeMs
|
||||
mtimeMs,
|
||||
filterMatched: classification.allowed,
|
||||
filterReason: classification.reason
|
||||
}));
|
||||
}
|
||||
return files;
|
||||
@@ -353,6 +385,7 @@ class FolderMonitor extends EventEmitter {
|
||||
|
||||
async _reconcile(generation) {
|
||||
if (!this._acceptCallback(generation)) return;
|
||||
this._nextReconcileAt = this._now() + this._reconcileIntervalMs;
|
||||
const trigger = this._reachable === false ? 'reconnect' : 'interval';
|
||||
await this.scan({ emitFiles: true, trigger });
|
||||
}
|
||||
|
||||
+54
-1
@@ -105,8 +105,61 @@ function createUploadAuditWriter(options) {
|
||||
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) {
|
||||
return typeof targetPath === 'string' && targetPath ? pathApi.dirname(targetPath) : fallbackDirectory;
|
||||
}
|
||||
|
||||
module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, getLogOpenDirectory };
|
||||
module.exports = { createInternalLogPathResolver, createInternalLogWriter, createUploadAuditWriter, createBufferedInternalLogFlusher, getLogOpenDirectory };
|
||||
|
||||
@@ -30,7 +30,7 @@ const RemoteServer = require('./lib/remote-server');
|
||||
const { maybeRotateLogFile } = require('./lib/log-rotation');
|
||||
const { hosterLogToFileEnabled } = require('./lib/log-policy');
|
||||
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 { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
|
||||
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
|
||||
@@ -39,6 +39,7 @@ const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||
const { createAgent } = require('./lib/diagnostics-agent');
|
||||
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
|
||||
const { inspectImportEntries, inspectReadableImportPath } = require('./lib/import-preflight');
|
||||
const { normalizeAutomationSettings } = require('./lib/automation-control');
|
||||
|
||||
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||
_eventLoopDelay.enable();
|
||||
@@ -289,7 +290,9 @@ function restoreClosePreparation(attempt, clearRestart = true) {
|
||||
if (closeFolderMonitorWasRunning) {
|
||||
closeFolderMonitorWasRunning = false;
|
||||
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'));
|
||||
return true;
|
||||
@@ -558,21 +561,16 @@ function getRotLogPath() {
|
||||
}
|
||||
const _rotLogBuffer = [];
|
||||
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() {
|
||||
if (_rotLogWriting || _rotLogBuffer.length === 0) return;
|
||||
const chunk = _rotLogBuffer.join('');
|
||||
_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);
|
||||
void _rotLogFlusher.flush('rot-log').then(written => {
|
||||
if (written === false) debugLog('rot-log append failed: no writable target');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1686,13 +1684,8 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
const launchConfig = configStore.load();
|
||||
const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor;
|
||||
if (fm && fm.enabled && fm.folderPath && fm.paused !== true) {
|
||||
startFolderMonitor(fm);
|
||||
if (!fs.existsSync(fm.folderPath)) {
|
||||
void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => {
|
||||
debugLog(`folder-monitor startup scan failed: ${error.message}`);
|
||||
});
|
||||
}
|
||||
if (fm && fm.enabled && fm.folderPath) {
|
||||
await startFolderMonitor(fm);
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog(`folder-monitor auto-start failed: ${err.message}`);
|
||||
@@ -1785,7 +1778,7 @@ app.on('will-quit', () => {
|
||||
} catch {}
|
||||
try {
|
||||
if (_rotLogBuffer.length) {
|
||||
_rotLogWriter.flushSync(_rotLogBuffer, 'rot-log');
|
||||
_rotLogFlusher.flushSync('rot-log');
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
@@ -2802,7 +2795,7 @@ async function syncImportedRuntime(config) {
|
||||
try {
|
||||
folderMonitor.stop();
|
||||
const folderSettings = config.globalSettings.folderMonitor;
|
||||
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) startFolderMonitor(folderSettings);
|
||||
if (folderSettings && folderSettings.enabled && folderSettings.folderPath) await startFolderMonitor(folderSettings);
|
||||
} catch (error) {
|
||||
debugLog(`backup folder monitor sync failed: ${error.message}`);
|
||||
warnings.push('Ordnerüberwachung');
|
||||
@@ -3273,14 +3266,15 @@ async function withAutomationStatusSuppressed(operation) {
|
||||
|
||||
function automationStatusSnapshot() {
|
||||
const settings = configStore.load().globalSettings?.folderMonitor || {};
|
||||
const normalized = normalizeAutomationSettings(settings);
|
||||
return Object.freeze({
|
||||
...folderMonitor.status(),
|
||||
enabled: settings.enabled === true,
|
||||
configured: String(settings.folderPath || '').trim().length > 0,
|
||||
paused: settings.paused === true,
|
||||
pausedAt: settings.pausedAt ?? null,
|
||||
queueLimitJobs: settings.queueLimitJobs,
|
||||
reconcileIntervalMinutes: settings.reconcileIntervalMinutes
|
||||
queueLimitJobs: normalized.queueLimitJobs,
|
||||
reconcileIntervalMinutes: normalized.reconcileIntervalMinutes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3318,11 +3312,25 @@ function bindFolderMonitorEvents(settings) {
|
||||
});
|
||||
}
|
||||
|
||||
function startFolderMonitor(settings) {
|
||||
async function startFolderMonitor(settings) {
|
||||
try {
|
||||
folderMonitor.stop();
|
||||
bindFolderMonitorEvents(settings);
|
||||
const result = folderMonitor.start(settings);
|
||||
const persisted = configStore.load().globalSettings?.folderMonitor || {};
|
||||
const normalized = normalizeAutomationSettings(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}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
@@ -3333,16 +3341,14 @@ function startFolderMonitor(settings) {
|
||||
|
||||
async function resumeFolderMonitor(settings) {
|
||||
bindFolderMonitorEvents(settings);
|
||||
const result = await folderMonitor.resume(settings);
|
||||
const result = await folderMonitor.resume(settings, { reconcile: false });
|
||||
debugLog(`folder-monitor resumed: ${settings.folderPath}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
ipcMain.handle('folder-monitor:start', (_event, settings) => {
|
||||
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
||||
return { error: 'Automatik ist pausiert' };
|
||||
}
|
||||
const result = startFolderMonitor(settings);
|
||||
ipcMain.handle('folder-monitor:start', async (_event, settings) => {
|
||||
const result = await startFolderMonitor(settings);
|
||||
if (result?.paused === true) return { error: 'Automatik ist pausiert' };
|
||||
return { ok: true, includesExisting: result?.includesExisting === true };
|
||||
});
|
||||
|
||||
@@ -3383,10 +3389,23 @@ ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle
|
||||
}));
|
||||
|
||||
ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => {
|
||||
let result;
|
||||
await withAutomationStatusSuppressed(async () => {
|
||||
const latest = configStore.load();
|
||||
const settings = latest.globalSettings?.folderMonitor || {};
|
||||
const resumedSettings = { ...settings, paused: false, pausedAt: null };
|
||||
const normalized = normalizeAutomationSettings(settings);
|
||||
const pausedSettings = {
|
||||
...settings,
|
||||
queueLimitJobs: normalized.queueLimitJobs,
|
||||
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({
|
||||
globalSettings: {
|
||||
...latest.globalSettings,
|
||||
@@ -3394,10 +3413,26 @@ ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async gener
|
||||
}
|
||||
});
|
||||
if (resumedSettings.enabled && resumedSettings.folderPath) {
|
||||
await resumeFolderMonitor(resumedSettings);
|
||||
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
|
||||
}
|
||||
});
|
||||
return publishAutomationStatus(null, generation);
|
||||
} catch {}
|
||||
result = { error: 'Automatik konnte nicht fortgesetzt werden' };
|
||||
}
|
||||
});
|
||||
return publishAutomationStatus(result, generation);
|
||||
}));
|
||||
|
||||
ipcMain.handle('folder-monitor:test-scan', () => {
|
||||
@@ -3405,6 +3440,9 @@ ipcMain.handle('folder-monitor:test-scan', () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('folder-monitor:reconcile', () => {
|
||||
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
|
||||
return { error: 'Automatik ist pausiert' };
|
||||
}
|
||||
return folderMonitor.scan({ emitFiles: true, trigger: 'manual' });
|
||||
});
|
||||
|
||||
|
||||
+98
-38
@@ -67,12 +67,15 @@ let uploading = false;
|
||||
let healthCheckRunning = false;
|
||||
let automationRuntimeStatus = Object.freeze({});
|
||||
let automationRuntimeStatusAvailable = false;
|
||||
let automationRuntimeStartedAt = null;
|
||||
let automationPauseResumeBusy = false;
|
||||
let automationTestGeneration = 0;
|
||||
let automationTestReturnFocus = null;
|
||||
let automationTestInertState = [];
|
||||
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 managedOnlineBackupsAuthoritative = false;
|
||||
let managedOnlineBackupMutationGeneration = 0;
|
||||
@@ -482,14 +485,6 @@ function automationSettings() {
|
||||
|
||||
function applyAutomationRuntimeStatus(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) {
|
||||
const folderMonitor = config.globalSettings.folderMonitor || {};
|
||||
config.globalSettings.folderMonitor = {
|
||||
@@ -532,11 +527,9 @@ function createAutomationStatusSnapshot() {
|
||||
&& availableSlots < configuredTargetCount;
|
||||
const telemetry = window.AutomationControl.rollDailyTelemetry(folderSettings.telemetry);
|
||||
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 nextReconcileAt = automationRuntimeStatus.running === true && automationRuntimeStatus.paused !== true && lastReconcileAt
|
||||
? lastReconcileAt + normalized.reconcileIntervalMinutes * 60000
|
||||
: null;
|
||||
const nextReconcileAt = automationTimestamp(automationRuntimeStatus.nextReconcileAt);
|
||||
const snapshot = {
|
||||
...automationRuntimeStatus,
|
||||
enabled: folderSettings.enabled === true,
|
||||
@@ -569,6 +562,9 @@ async function evaluateAutomationCandidates(files, options = {}) {
|
||||
}
|
||||
const candidates = [...candidateMap.values()];
|
||||
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 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 || [])
|
||||
@@ -585,12 +581,17 @@ async function evaluateAutomationCandidates(files, options = {}) {
|
||||
uploadLogRows: uploadLog
|
||||
});
|
||||
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 inspection = await window.api.inspectImportFiles(
|
||||
unprocessed,
|
||||
_pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path)
|
||||
);
|
||||
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 => ({
|
||||
...(metadata.get(normalizeAutomationPath(file?.path)) || {}),
|
||||
...file
|
||||
@@ -610,7 +611,7 @@ async function evaluateAutomationCandidates(files, options = {}) {
|
||||
const currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
|
||||
const availableSlots = normalizedSettings.queueLimitJobs === 0 ? null : Math.max(0, normalizedSettings.queueLimitJobs - currentJobCount);
|
||||
const admission = window.AutomationControl.planAtomicAdmissions({
|
||||
candidates: plannedCandidates,
|
||||
candidates: plannedCandidates.filter(candidate => candidate.eligibleJobCount > 0),
|
||||
currentJobCount,
|
||||
queueLimitJobs: normalizedSettings.queueLimitJobs
|
||||
});
|
||||
@@ -618,14 +619,28 @@ async function evaluateAutomationCandidates(files, options = {}) {
|
||||
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
|
||||
const admittedFiles = plannedCandidates.filter(candidate => admittedPaths.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 = {
|
||||
found: candidates.length,
|
||||
filterMatched: matched.length,
|
||||
alreadyProcessed: processed.processedPaths.length,
|
||||
unavailable: Number(inspection?.unavailableCount) || 0,
|
||||
alreadyProcessed: processed.processedPaths.length + inspectionDuplicatePaths.size,
|
||||
unavailable: unavailablePaths.size,
|
||||
sizeLimitedJobs: plannedCandidates.length * selectedHosters.length - resultingJobs,
|
||||
acceptedFiles: plannedCandidates.length,
|
||||
acceptedFiles: selectedHosters.length === 0 ? plannedCandidates.length : actionableCandidates.length,
|
||||
selectedTargets: selectedHosters.length,
|
||||
resultingJobs,
|
||||
availableSlots,
|
||||
@@ -639,15 +654,16 @@ async function evaluateAutomationCandidates(files, options = {}) {
|
||||
selectedHosters,
|
||||
ownedPendingPaths: [...ownedPendingPaths],
|
||||
candidates: plannedCandidates,
|
||||
classifications,
|
||||
admittedFiles,
|
||||
deferredFiles,
|
||||
summary,
|
||||
telemetryDelta: {
|
||||
detected: summary.found,
|
||||
queued: admittedFiles.length,
|
||||
skipped: summary.alreadyProcessed + summary.unavailable,
|
||||
skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).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 currentJobCount = window.AutomationControl.countAutomaticQueueJobs(queueJobs);
|
||||
const admission = window.AutomationControl.planAtomicAdmissions({
|
||||
candidates,
|
||||
candidates: candidates.filter(candidate => candidate.eligibleJobCount > 0),
|
||||
currentJobCount,
|
||||
queueLimitJobs: normalizedSettings.queueLimitJobs
|
||||
});
|
||||
@@ -748,12 +764,25 @@ async function applyAutomationEvaluation(evaluation) {
|
||||
const deferredPaths = new Set(admission.deferredPaths.map(normalizeAutomationPath));
|
||||
const admittedFiles = candidates.filter(candidate => admittedPaths.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 = {
|
||||
detected: evaluation.summary.found,
|
||||
detected: classifications.length,
|
||||
queued: admittedFiles.length,
|
||||
skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable,
|
||||
skipped: classifications.filter(entry => skippedReasons.has(entry.reason)).length,
|
||||
deferred: deferredFiles.length,
|
||||
lastDetectedName: admittedFiles.at(-1)?.name || ''
|
||||
lastDetectedName: evaluation.telemetryDelta?.lastDetectedName || ''
|
||||
};
|
||||
if (admittedFiles.length === 0) {
|
||||
const telemetryResult = await persistAutomationTelemetry(telemetryDelta);
|
||||
@@ -907,9 +936,13 @@ function renderAutomationStatusSnapshot(snapshot) {
|
||||
}
|
||||
const queueTrack = document.getElementById('automationQueueMeterTrack');
|
||||
if (queueTrack) {
|
||||
if (snapshot.queueLimitJobs === 0) {
|
||||
queueTrack.removeAttribute('aria-valuenow');
|
||||
queueTrack.removeAttribute('aria-valuemax');
|
||||
} else {
|
||||
queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount));
|
||||
if (snapshot.queueLimitJobs === 0) queueTrack.removeAttribute('aria-valuemax');
|
||||
else queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs));
|
||||
queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs));
|
||||
}
|
||||
queueTrack.setAttribute('aria-valuetext', queueText);
|
||||
}
|
||||
const telemetry = snapshot.telemetry || {};
|
||||
@@ -925,7 +958,7 @@ function renderAutomationStatusSnapshot(snapshot) {
|
||||
const errorRow = document.getElementById('automationLastErrorRow');
|
||||
const errorText = String(snapshot.error || telemetry.lastError || '');
|
||||
if (errorRow) errorRow.hidden = errorText.length === 0;
|
||||
setAutomationText('automationLastError', errorText);
|
||||
setAutomationText('automationLastError', errorText ? localizeUiText(errorText) : '');
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@@ -1151,7 +1184,7 @@ async function toggleAutomationPauseResume() {
|
||||
updateStatusBar();
|
||||
}
|
||||
} 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 {
|
||||
automationPauseResumeBusy = false;
|
||||
refreshAutomationControlCenter();
|
||||
@@ -1166,11 +1199,12 @@ window.runFolderMonitorTestScan = runFolderMonitorTestScan;
|
||||
function surfaceAutomationOutcome(result) {
|
||||
if (result?.ok !== false) return '';
|
||||
const message = result.warning || result.error || 'Automatische Aufnahme konnte nicht abgeschlossen werden.';
|
||||
showCopyToast(message, 6500);
|
||||
return message;
|
||||
const localized = localizeUiText(message);
|
||||
showCopyToast(localized, 6500);
|
||||
return localized;
|
||||
}
|
||||
|
||||
async function handleFolderMonitorFiles(files) {
|
||||
async function processFolderMonitorFiles(files) {
|
||||
window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)');
|
||||
const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' });
|
||||
const result = await applyAutomationEvaluation(evaluation);
|
||||
@@ -1178,6 +1212,38 @@ async function handleFolderMonitorFiles(files) {
|
||||
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 ---
|
||||
async function init() {
|
||||
try {
|
||||
@@ -2515,10 +2581,6 @@ function suppressPreviewKeysStillSelected(keys) {
|
||||
function buildQueuePreview() {
|
||||
const hosters = getSelectedHosters();
|
||||
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) {
|
||||
const existingKeys = new Set();
|
||||
@@ -2532,7 +2594,6 @@ function buildQueuePreview() {
|
||||
const key = `${file.path}|${hoster}`;
|
||||
return !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key);
|
||||
});
|
||||
if (availableSlots !== null && eligibleHosters.length > availableSlots) continue;
|
||||
for (const hoster of eligibleHosters) {
|
||||
const key = `${file.path}|${hoster}`;
|
||||
const job = {
|
||||
@@ -2545,7 +2606,6 @@ function buildQueuePreview() {
|
||||
queueJobs.push(job);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
if (availableSlots !== null) availableSlots -= eligibleHosters.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6973,7 +7033,7 @@ async function performSaveSettings(options = {}) {
|
||||
const normalizedAutomationInputs = window.AutomationControl.normalizeAutomationSettings({
|
||||
...curFm,
|
||||
queueLimitJobs: document.getElementById('fmQueueLimitInput')?.value ?? curFm.queueLimitJobs,
|
||||
reconcileIntervalMinutes: document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes
|
||||
reconcileIntervalMinutes: Number(document.getElementById('fmReconcileIntervalInput')?.value ?? curFm.reconcileIntervalMinutes)
|
||||
});
|
||||
|
||||
const globalSettings = {
|
||||
|
||||
@@ -296,6 +296,13 @@
|
||||
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
|
||||
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
|
||||
['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 nicht erreichbar', 'Folder unavailable'],
|
||||
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
|
||||
|
||||
@@ -37,7 +37,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
|
||||
pausedAt: 1700
|
||||
}), {
|
||||
queueLimitJobs: 42,
|
||||
reconcileIntervalMinutes: 15,
|
||||
reconcileIntervalMinutes: 5,
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
@@ -47,6 +47,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
|
||||
});
|
||||
|
||||
+149
-20
@@ -22,9 +22,11 @@ function createWatcherHarness() {
|
||||
function createManualTimers() {
|
||||
const intervals = new Set();
|
||||
const timeouts = new Set();
|
||||
const intervalDelays = [];
|
||||
return {
|
||||
setIntervalFn(callback) {
|
||||
setIntervalFn(callback, delay) {
|
||||
intervals.add(callback);
|
||||
intervalDelays.push(delay);
|
||||
return callback;
|
||||
},
|
||||
clearIntervalFn(callback) {
|
||||
@@ -45,7 +47,8 @@ function createManualTimers() {
|
||||
timeouts.delete(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);
|
||||
});
|
||||
|
||||
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({
|
||||
files: [
|
||||
{ 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 });
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness();
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
|
||||
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
|
||||
@@ -331,7 +456,7 @@ test('late watcher add callbacks are ignored after pause', async () => {
|
||||
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 watchers = [];
|
||||
const newFiles = [];
|
||||
@@ -356,7 +481,10 @@ test('resume preserves session duplicate history', async () => {
|
||||
await monitor.resume(settings);
|
||||
watchers[1].emit('add', 'C:\\watch\\same.mkv');
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -385,11 +513,11 @@ test('watcher add paused before batch timeout is emitted exactly once by resume
|
||||
await monitor.pause();
|
||||
assert.deepEqual(newFiles, []);
|
||||
await monitor.resume(settings);
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
assert.equal(monitor.status().seenCount, 1);
|
||||
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]);
|
||||
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 watchers = [];
|
||||
const newFiles = [];
|
||||
@@ -418,7 +546,7 @@ test('synchronous pause during successful batch emission does not duplicate on r
|
||||
await pausePromise;
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -452,8 +580,9 @@ test('pause rollback never deletes historical seen state from a dedupe-off batch
|
||||
watchers[1].emit('add', filePath);
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
await monitor.pause();
|
||||
discoverExisting = true;
|
||||
await monitor.resume(dedupeOn);
|
||||
watchers[2].emit('add', filePath);
|
||||
await timers.runTimeouts();
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
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);
|
||||
});
|
||||
|
||||
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 detached = `${root}-detached`;
|
||||
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(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
|
||||
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
|
||||
monitor.on('new-files', (paths) => {
|
||||
const descriptors = paths.map(filePath => ({
|
||||
path: filePath,
|
||||
name: path.basename(filePath),
|
||||
mtimeMs: fs.statSync(filePath).mtimeMs
|
||||
}));
|
||||
monitor.on('new-files', (files) => {
|
||||
const descriptors = files.map(file => typeof file === 'string' ? {
|
||||
path: file,
|
||||
name: path.basename(file),
|
||||
mtimeMs: fs.statSync(file).mtimeMs
|
||||
} : file).filter(file => file.filterMatched !== false);
|
||||
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
|
||||
const unprocessed = new Set(processed.unprocessedPaths);
|
||||
const candidates = descriptors
|
||||
@@ -595,12 +724,12 @@ test('real temporary folder converges through startup interval capacity recovery
|
||||
recursive: true,
|
||||
extensions: 'mkv',
|
||||
filterMode: 'include',
|
||||
skipDuplicates: false,
|
||||
skipDuplicates: true,
|
||||
reconcileIntervalMinutes: 5
|
||||
});
|
||||
|
||||
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.deepEqual(admissions[0], {
|
||||
trigger: 'startup',
|
||||
|
||||
@@ -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 fortgesetzt werden.', 'Automation could not be resumed.'],
|
||||
['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 nicht erreichbar', 'Folder unavailable'],
|
||||
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
|
||||
|
||||
@@ -45,6 +45,8 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
const saves = [];
|
||||
const pauseDeferred = createDeferred();
|
||||
const resumeDeferred = createDeferred();
|
||||
const configuredSettings = [];
|
||||
const startedSettings = [];
|
||||
let publishStatus = () => {};
|
||||
let state = {
|
||||
globalSettings: {
|
||||
@@ -60,9 +62,22 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
};
|
||||
const folderMonitor = new (require('node:events').EventEmitter)();
|
||||
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.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 = () => {
|
||||
order.push('pause');
|
||||
publishStatus();
|
||||
@@ -80,7 +95,10 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
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 = {
|
||||
load: () => structuredClone(state),
|
||||
save: config => {
|
||||
@@ -101,6 +119,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
folderMonitor,
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||
path,
|
||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||
uploadManager
|
||||
@@ -109,11 +128,17 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
publishStatus = () => context.publishAutomationStatus();
|
||||
return {
|
||||
handlers,
|
||||
configuredSettings,
|
||||
context,
|
||||
order,
|
||||
pauseDeferred,
|
||||
resumeDeferred,
|
||||
saves,
|
||||
sent,
|
||||
setFolderMonitorState(value) {
|
||||
state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value };
|
||||
},
|
||||
startedSettings,
|
||||
state: () => structuredClone(state),
|
||||
publishStatus
|
||||
};
|
||||
@@ -422,6 +447,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
folderMonitor.running = true;
|
||||
folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true });
|
||||
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.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 }; };
|
||||
@@ -458,6 +484,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
folderMonitor,
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||
path,
|
||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||
uploadManager
|
||||
@@ -486,7 +513,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
state.globalSettings.folderMonitor.pausedAt = 1;
|
||||
folderMonitor.running = false;
|
||||
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(sent.length, 1);
|
||||
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();
|
||||
await flushMicrotasks();
|
||||
harness.pauseDeferred.resolve();
|
||||
await waitForCondition(() => harness.order.includes('resume'));
|
||||
harness.resumeDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
assert.equal(harness.saves.length, 2);
|
||||
assert.equal(harness.saves[1].paused, false);
|
||||
harness.saves[1].deferred.resolve();
|
||||
await flushMicrotasks();
|
||||
harness.resumeDeferred.resolve();
|
||||
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.sent.length, 1);
|
||||
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 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);
|
||||
harness.saves[0].deferred.resolve();
|
||||
await flushMicrotasks();
|
||||
harness.resumeDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
assert.equal(harness.saves.length, 2);
|
||||
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();
|
||||
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.sent.length, 1);
|
||||
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();
|
||||
await waitForCondition(() => harness.order.includes('pause'));
|
||||
harness.pauseDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
await waitForCondition(() => harness.order.includes('resume'));
|
||||
harness.publishStatus();
|
||||
|
||||
assert.equal(harness.sent.length, 0);
|
||||
|
||||
harness.saves[1].deferred.resolve();
|
||||
await waitForCondition(() => harness.order.includes('resume'));
|
||||
harness.resumeDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
harness.saves[1].deferred.resolve();
|
||||
await Promise.all([pause, resume]);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
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);
|
||||
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, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u);
|
||||
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/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);
|
||||
});
|
||||
|
||||
+183
-18
@@ -81,6 +81,9 @@ let automationProbe = {
|
||||
saveSettingsError: '',
|
||||
testScanError: '',
|
||||
deferTestScan: false,
|
||||
deferInspect: false,
|
||||
activeInspections: 0,
|
||||
maxConcurrentInspections: 0,
|
||||
dryScan: { files: [], reachable: true, trigger: 'test' },
|
||||
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
@@ -184,6 +187,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
saveSettingsError: String(value.saveSettingsError || ''),
|
||||
testScanError: String(value.testScanError || ''),
|
||||
deferTestScan: value.deferTestScan === true,
|
||||
deferInspect: value.deferInspect === true,
|
||||
activeInspections: 0,
|
||||
maxConcurrentInspections: 0,
|
||||
dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' },
|
||||
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
@@ -194,13 +200,17 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getAutomationProbeState() {
|
||||
return {
|
||||
readCalls: { ...automationProbe.readCalls },
|
||||
activeInspections: automationProbe.activeInspections,
|
||||
maxConcurrentInspections: automationProbe.maxConcurrentInspections,
|
||||
mutationCalls: automationProbe.mutationCalls.map(value => [...value]),
|
||||
logs: [...automationProbe.logs],
|
||||
savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value)))
|
||||
};
|
||||
},
|
||||
inspectImportFiles(entries, existingPaths) {
|
||||
async inspectImportFiles(entries, existingPaths) {
|
||||
automationProbe.readCalls.inspect++;
|
||||
automationProbe.activeInspections++;
|
||||
automationProbe.maxConcurrentInspections = Math.max(automationProbe.maxConcurrentInspections, automationProbe.activeInspections);
|
||||
const candidates = Array.isArray(entries) ? entries : [];
|
||||
const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase();
|
||||
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 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,
|
||||
duplicateCount: duplicates.length,
|
||||
unavailableCount: unavailable.length,
|
||||
@@ -224,7 +236,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
accepted,
|
||||
duplicates,
|
||||
unavailable
|
||||
});
|
||||
};
|
||||
},
|
||||
getHistory() {
|
||||
automationProbe.readCalls.history++;
|
||||
@@ -805,13 +817,76 @@ contextBridge.exposeInMainWorld('api', {
|
||||
handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }])
|
||||
]);
|
||||
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,
|
||||
matchingPaths: [...new Set(queueJobs
|
||||
.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path))
|
||||
.map(job => normalizeAutomationPath(job.file)))],
|
||||
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 = [
|
||||
{ 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 }
|
||||
@@ -833,6 +908,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
||||
currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
|
||||
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,
|
||||
manualSelectionFilesAfterApply,
|
||||
plannedHostsBeforeRebuild,
|
||||
@@ -1802,7 +1879,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').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 waitFor = async predicate => {
|
||||
@@ -1820,6 +1897,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
folderPath: 'C:\\\\watch',
|
||||
lastScanAt: fixedNow - 60000,
|
||||
startedAt: fixedNow - 3600000,
|
||||
nextReconcileAt: fixedNow + 123456,
|
||||
error: ''
|
||||
};
|
||||
setUiLanguage('de');
|
||||
@@ -1882,8 +1960,17 @@ contextBridge.exposeInMainWorld('api', {
|
||||
queueLimitMin: queueLimitInput?.min || null,
|
||||
intervalDefault: intervalInput?.value || null,
|
||||
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) {
|
||||
queueLimitInput.value = '0';
|
||||
queueLimitInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
@@ -1908,6 +1995,24 @@ contextBridge.exposeInMainWorld('api', {
|
||||
renderAutomationStatusSnapshot(baseSnapshot);
|
||||
}
|
||||
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;
|
||||
const pausedSnapshot = Object.freeze({
|
||||
...originalSnapshotFactory(),
|
||||
@@ -2089,7 +2194,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
enabledAfterCancel,
|
||||
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 card = document.getElementById('automationStatusCard');
|
||||
@@ -2545,6 +2650,49 @@ app.whenReady().then(async () => {
|
||||
matchingPaths: ['c:/watch/parallel.mkv'],
|
||||
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, {
|
||||
readFailure: {
|
||||
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
||||
@@ -2569,8 +2717,10 @@ app.whenReady().then(async () => {
|
||||
admittedFiles: ['b.mkv'],
|
||||
deferred: 1,
|
||||
queued: 1,
|
||||
currentJobCount: 15000,
|
||||
unplannedJobs: 0,
|
||||
currentJobCount: 15001,
|
||||
unplannedJobs: 1,
|
||||
manualJobHosters: ['clouddrop.cc'],
|
||||
automationJobHosters: ['byse.sx', 'vidmoly.me'],
|
||||
selectedHostersAfterApply: ['clouddrop.cc'],
|
||||
manualSelectionFilesAfterApply: ['unplanned.mkv'],
|
||||
plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'],
|
||||
@@ -2578,7 +2728,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.status, {
|
||||
state: 'queue-limited',
|
||||
currentJobCount: 15000,
|
||||
currentJobCount: 15001,
|
||||
availableSlots: 0,
|
||||
queueLimited: true,
|
||||
frozen: true
|
||||
@@ -2842,15 +2992,15 @@ app.whenReady().then(async () => {
|
||||
assert.deepEqual(result.automationPipeline.fulfilledFeedback, {
|
||||
watcherWarning: {
|
||||
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
|
||||
feedback: ['Telemetrie konnte nicht gespeichert werden.']
|
||||
feedback: ['Telemetry could not be saved.']
|
||||
},
|
||||
watcherError: {
|
||||
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: {
|
||||
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,
|
||||
markers: 0,
|
||||
modalOpen: false,
|
||||
@@ -2919,6 +3069,9 @@ app.whenReady().then(async () => {
|
||||
assert.equal(result.automationControlCenter.initial.intervalDefault, '5');
|
||||
assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']);
|
||||
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, [
|
||||
{ state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', 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: '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, {
|
||||
snapshotCalls: 1,
|
||||
pauseButtonDisabled: false,
|
||||
@@ -3393,6 +3549,7 @@ test('persisted automation pause survives runtime restart and resumes one reconc
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('restartProbe', {
|
||||
status: () => ipcRenderer.invoke('automation:get-status'),
|
||||
testScan: () => ipcRenderer.invoke('folder-monitor:test-scan'),
|
||||
startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||
reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
|
||||
start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }),
|
||||
@@ -3421,6 +3578,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
|
||||
status: 'preview'
|
||||
};
|
||||
const initial = await window.restartProbe.status();
|
||||
const testScan = await window.restartProbe.testScan();
|
||||
const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' });
|
||||
const reconcile = await captureFailure(() => window.restartProbe.reconcile());
|
||||
const start = await window.restartProbe.start(preview);
|
||||
@@ -3443,6 +3601,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
|
||||
const final = await window.restartProbe.counters();
|
||||
window.__automationRestartResult = {
|
||||
initial,
|
||||
testScan,
|
||||
monitorStart,
|
||||
reconcile,
|
||||
start,
|
||||
@@ -3467,6 +3626,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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 { normalizeAutomationSettings } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'automation-control.js'))});
|
||||
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
|
||||
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
||||
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
||||
@@ -3650,18 +3810,23 @@ ${startupAutomation}
|
||||
});
|
||||
assert.equal(outcome.result.error, undefined);
|
||||
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.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.extend, { error: 'Automatik ist pausiert' });
|
||||
assert.equal(outcome.result.previewStatus, 'preview');
|
||||
assert.equal(outcome.result.beforeResume.watcherStarts, 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.afterResume.watcherStarts, 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.addJobsCalls, 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.watcherCloseCalls, 1);
|
||||
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.monitor.paused, true);
|
||||
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' }, () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));
|
||||
try {
|
||||
const appRoot = path.join(probeRoot, 'app');
|
||||
const userDataPath = path.join(probeRoot, 'user-data');
|
||||
const outputPath = path.join(probeRoot, 'result.json');
|
||||
@@ -3820,7 +3986,6 @@ async function waitFor(read, timeoutMs = 20000) {
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(probePath, probeSource, 'utf8');
|
||||
try {
|
||||
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
||||
const probeEnvironment = {
|
||||
...process.env,
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const {
|
||||
createInternalLogPathResolver,
|
||||
createInternalLogWriter,
|
||||
createBufferedInternalLogFlusher,
|
||||
createUploadAuditWriter,
|
||||
getLogOpenDirectory
|
||||
} = 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', () => {
|
||||
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\(''\)/);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const directory = createTempDirectory(t, 'mhu-upload-log-contract-');
|
||||
const userDataPath = path.join(directory, 'user-data');
|
||||
|
||||
Reference in New Issue
Block a user