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:
+15
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user