fix: prevent folder-monitor reuploads after completion

Discover base, daily, session, and fallback upload logs across configured, Desktop, and application-data locations without blocking the Electron main process.

Treat in-session completion keys as authoritative automation evidence so removing finished rows cannot cause periodic reconciliation to enqueue and upload the same file again.

Add real handler and done-remove-reconcile regressions covering custom extensions, mixed-case log names, fallback paths, stale evidence, and asynchronous log reads.
This commit is contained in:
Sucukdeluxe
2026-08-27 13:22:35 +02:00
parent c0153acfb2
commit 58b92856a6
6 changed files with 182 additions and 27 deletions
+15 -1
View File
@@ -97,7 +97,21 @@
return out;
}
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, VALID_MODES };
function isManagedUploadLogFileName(fileName, args) {
const value = String(fileName || '');
const options = args && typeof args === 'object' ? args : {};
const baseName = String(options.baseName || 'fileuploader');
const ext = String(options.ext || '.log');
const normalizedValue = value.toLowerCase();
const normalizedBaseName = baseName.toLowerCase();
const normalizedExt = ext.toLowerCase();
if (!normalizedExt || !normalizedValue.endsWith(normalizedExt)) return false;
if (stripModeStampFromFileName(normalizedValue) === `${normalizedBaseName}${normalizedExt}`) return true;
const stem = normalizedValue.slice(0, -normalizedExt.length);
return /^\d{2}-\d{2}-\d{4}-mdu-session-\d{2}-\d{2}(?:-\d+)?$/.test(stem);
}
const api = { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, stripModeStampFromFileName, isManagedUploadLogFileName, VALID_MODES };
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
+36 -23
View File
@@ -813,7 +813,7 @@ function getBaseLogFilePath() {
// given session lands in the same file. A close→reopen of the app starts a new
// main process, so a new SESSION_ID, so a new session file. A 6-digit random is
// appended as a cheap hedge against same-minute restart collisions.
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName } = require('./lib/log-mode');
const { resolveLogFileName, formatSessionStamp, formatDateStamp, stripModeStampFromFileName, isManagedUploadLogFileName } = require('./lib/log-mode');
const SESSION_ID = formatSessionStamp(new Date(), String(Math.floor(100000 + Math.random() * 900000)));
let _activeLogKey = null; // remembers (mode + date-or-session) so cache rolls correctly
let _activeLogPath = null;
@@ -3015,37 +3015,50 @@ ipcMain.handle('online-backup:restore', async (_event, key) => {
}
});
ipcMain.handle('read-own-upload-log', () => {
// Read all log files (base + daily logs) and return parsed entries
const entries = [];
ipcMain.handle('read-own-upload-log', async () => {
const entries = new Map();
const basePath = getBaseLogFilePath();
const dir = path.dirname(basePath);
const ext = path.extname(basePath);
const name = path.basename(basePath, ext);
// Collect all matching log files (base + daily variants)
const logFiles = [];
try {
for (const file of fs.readdirSync(dir)) {
if (file.startsWith(name) && file.endsWith(ext)) {
logFiles.push(path.join(dir, file));
}
}
} catch {}
if (logFiles.length === 0 && fs.existsSync(basePath)) {
logFiles.push(basePath);
}
for (const logPath of logFiles) {
const activeTarget = _resolveUploadLogTarget();
const directories = new Set([dir]);
if (activeTarget?.path) directories.add(path.dirname(activeTarget.path));
const desktop = getSafeDesktopDir();
if (desktop) directories.add(desktop);
try { directories.add(app.getPath('userData')); } catch {}
const logFiles = new Set();
for (const directory of directories) {
try {
const content = fs.readFileSync(logPath, 'utf-8');
for (const line of content.split('\n')) {
const parsed = parseUploadLogLine(line);
if (parsed) entries.push(parsed);
for (const file of fs.readdirSync(directory)) {
if (
isManagedUploadLogFileName(file, { baseName: name, ext })
|| isManagedUploadLogFileName(file, { baseName: 'fileuploader', ext: '.log' })
) {
logFiles.add(path.join(directory, file));
}
}
} catch {}
}
return entries;
if (activeTarget?.path && fs.existsSync(activeTarget.path)) logFiles.add(activeTarget.path);
if (fs.existsSync(basePath)) logFiles.add(basePath);
for (const logPath of logFiles) {
try {
const content = await fs.promises.readFile(logPath, 'utf-8');
for (const line of content.split('\n')) {
const parsed = parseUploadLogLine(line);
if (!parsed) continue;
const key = `${parsed.hoster.toLowerCase()}\u0000${parsed.fileName.toLowerCase()}`;
const previous = entries.get(key);
const timestamp = Number.isFinite(parsed.ts) ? parsed.ts : -Infinity;
const previousTimestamp = Number.isFinite(previous?.ts) ? previous.ts : -Infinity;
if (!previous || timestamp >= previousTimestamp) entries.set(key, parsed);
}
} catch {}
}
return [...entries.values()];
});
ipcMain.handle('import-upload-log', async () => {
+5 -1
View File
@@ -599,9 +599,13 @@ async function evaluateAutomationCandidates(files, options = {}) {
.map(value => String(value || '').trim())
.filter(Boolean)));
const { history, uploadLog } = options.evidenceSnapshot || await loadAutomationEvidenceSnapshot();
const completedPaths = [..._completedUploadKeys].map(key => {
const separator = key.lastIndexOf('|');
return separator > 0 ? key.slice(0, separator) : '';
}).filter(Boolean);
const processed = window.AutomationControl.classifyProcessedCandidates({
candidates: matched,
queuePaths: [...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path)],
queuePaths: [...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path), ...completedPaths],
historyRows: flattenAutomationHistoryRows(history),
uploadLogRows: uploadLog
});
+16 -1
View File
@@ -1,6 +1,6 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp } = require('../lib/log-mode');
const { normalizeLogMode, resolveLogFileName, formatDateStamp, formatSessionStamp, isManagedUploadLogFileName } = require('../lib/log-mode');
// --- normalizeLogMode ---
@@ -88,6 +88,21 @@ test('resolveLogFileName: unknown mode is treated as single', () => {
);
});
test('managed upload-log discovery includes session logs and excludes unrelated logs', () => {
assert.equal(typeof isManagedUploadLogFileName, 'function');
const options = { baseName: 'fileuploader', ext: '.log' };
assert.equal(isManagedUploadLogFileName('fileuploader.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.log', options), true);
assert.equal(isManagedUploadLogFileName('fileuploader-session-2026-08-27_05-40-59-1234.log', options), true);
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.log', options), true);
assert.equal(isManagedUploadLogFileName('FILEUPLOADER-2026-08-27.LOG', options), true);
assert.equal(isManagedUploadLogFileName('27-08-2026-MDU-SESSION-05-40-599797.LOG', options), true);
assert.equal(isManagedUploadLogFileName('upload-audit.log', options), false);
assert.equal(isManagedUploadLogFileName('account-rotation.log', options), false);
assert.equal(isManagedUploadLogFileName('upload-debug.log', options), false);
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.txt', options), false);
});
// --- stripModeStampFromFileName ---
const { stripModeStampFromFileName } = require('../lib/log-mode');
+51
View File
@@ -222,6 +222,57 @@ test('packages every Electron preload referenced by the main process', () => {
assert.equal(packageJson.build.win.signAndEditExecutable, false);
});
test('read-own-upload-log discovers base daily session and both fallback directories without synchronous reads', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const blockStart = mainSource.indexOf("ipcMain.handle('read-own-upload-log'");
const blockEnd = mainSource.indexOf("\nipcMain.handle('import-upload-log'", blockStart);
assert.notEqual(blockStart, -1);
assert.notEqual(blockEnd, -1);
const handlers = new Map();
const configured = 'C:\\configured';
const desktop = 'C:\\desktop';
const userData = 'C:\\user-data';
const entriesByDirectory = new Map([
[configured, ['custom.txt', 'custom-2026-08-27.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']],
[desktop, ['FILEUPLOADER-2026-08-26.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']],
[userData, ['fileuploader-2026-08-25.log', '25-08-2026-mdu-session-05-40-333333.log', 'upload-debug.log']]
]);
const fileNames = new Map();
for (const [directory, names] of entriesByDirectory) {
for (const name of names) {
if (name.includes('audit') || name.includes('rotation') || name.includes('debug')) continue;
fileNames.set(path.win32.join(directory, name), `${name}.mkv`);
}
}
const fakeFs = {
readdirSync: directory => entriesByDirectory.get(directory) || [],
existsSync: filePath => fileNames.has(filePath),
readFileSync: () => { throw new Error('synchronous read forbidden'); },
promises: {
readFile: async filePath => require('../lib/upload-log').formatUploadLogLine(
new Date(2026, 7, 27, 5, 40, 0),
'voe.sx',
'https://voe.sx/e/test',
fileNames.get(filePath)
)
}
};
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
_resolveUploadLogTarget: () => ({ path: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt') }),
app: { getPath: name => name === 'desktop' ? desktop : userData },
fs: fakeFs,
getBaseLogFilePath: () => path.win32.join(configured, 'custom.txt'),
getSafeDesktopDir: () => desktop,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
isManagedUploadLogFileName: require('../lib/log-mode').isManagedUploadLogFileName,
parseUploadLogLine: require('../lib/upload-log').parseUploadLogLine,
path: path.win32
});
const entries = await handlers.get('read-own-upload-log')();
assert.deepEqual([...entries.map(entry => entry.fileName)].sort(), [...fileNames.values()].sort());
});
test('exposes managed online backup operations through narrow IPC boundaries', () => {
const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
+59 -1
View File
@@ -685,6 +685,57 @@ contextBridge.exposeInMainWorld('api', {
acceptedNames: historyEvaluation.candidates.map(file => file.name).sort(),
resultingJobs: historyEvaluation.summary.resultingJobs
};
_completedUploadKeys.clear();
const completedFile = { path: 'C:\\history\\completed-in-session.mkv', name: 'completed-in-session.mkv', size: 1 };
config.globalSettings.removeFromQueueOnDone = true;
config.globalSettings.folderMonitor = {
enabled: true,
folderPath: 'C:\\history',
hosters: ['doodstream.com'],
autoStart: false,
queueLimitJobs: 15000,
paused: false
};
const completedJob = {
id: 'completed-in-session',
file: completedFile.path,
fileName: completedFile.name,
hoster: 'doodstream.com',
status: 'queued',
bytesTotal: 1,
automationAdmission: true
};
queueJobs = [completedJob];
selectedFiles = [];
uploading = false;
rebuildJobIndex();
window.api.configureAutomationProbe({ paused: false, history: [], uploadLog: [] });
handleProgress({
jobId: completedJob.id,
fileName: completedJob.fileName,
hoster: completedJob.hoster,
status: 'done',
bytesUploaded: 1,
bytesTotal: 1,
progress: 1,
result: { download_url: 'https://doodstream.com/d/completed-in-session' }
});
_doneRemovalCoalescer?.drainSync();
automationEvidenceSnapshotGeneration++;
automationEvidenceSnapshotCache = null;
const removedAfterDone = !queueJobs.some(job => job.id === completedJob.id);
const completedKeyPresent = _completedUploadKeys.has(completedFile.path + '|doodstream.com');
const completedResult = await handleFolderMonitorFiles([completedFile]);
const completedProbe = await window.api.getAutomationProbeState();
const completedEvidence = {
removedAfterDone,
completedKeyPresent,
admittedFiles: completedResult.admittedFiles.length,
matchingQueueJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(completedFile.path)).length,
startOrInjectCalls: completedProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length
};
_completedUploadKeys.clear();
config.globalSettings.removeFromQueueOnDone = false;
config.globalSettings.folderMonitor = {
enabled: true,
folderPath: 'C:\\\\pending',
@@ -2020,7 +2071,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, evidenceSnapshotDrain, separatedEventEvidence, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, pauseMarker, resumeQueue, paused };
return { dry, manualTest, historyEvidence, completedEvidence, pendingDedup, parallelAdmission, evidenceSnapshotDrain, separatedEventEvidence, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, pauseMarker, resumeQueue, paused };
})()`;
const automationControlCenterScript = `(async () => {
const waitFor = async predicate => {
@@ -2799,6 +2850,13 @@ app.whenReady().then(async () => {
acceptedNames: ['aborted.mkv', 'all-failed.mkv', 'error.mkv', 'same-name.mkv', 'same-name.mkv', 'skipped.mkv'],
resultingJobs: 6
});
assert.deepEqual(result.automationPipeline.completedEvidence, {
removedAfterDone: true,
completedKeyPresent: true,
admittedFiles: 0,
matchingQueueJobs: 0,
startOrInjectCalls: 0
});
assert.deepEqual(result.automationPipeline.pendingDedup, {
evaluatedNames: ['new.mkv'],
pendingPaths: ['c:/pending/overlap.mkv'],