fix: reject ambiguous queue job identities

This commit is contained in:
Sucukdeluxe
2026-08-26 12:13:45 +02:00
parent debc361160
commit 3f9f7c212f
2 changed files with 227 additions and 25 deletions
+58 -23
View File
@@ -723,12 +723,10 @@ async function applyAutomationEvaluation(evaluation) {
if (await isAutomationPaused()) { if (await isAutomationPaused()) {
return freezeAutomationValue({ ok: false, error: 'Automatik ist pausiert', warning: null, admittedFiles: [], deferredFiles, paused: true, dryRun: false }); return freezeAutomationValue({ ok: false, error: 'Automatik ist pausiert', warning: null, admittedFiles: [], deferredFiles, paused: true, dryRun: false });
} }
const cleanupPreparation = prepareSourceCleanup(newJobs); const addRequest = createAddJobsRequest(newJobs);
const cleanupPreparation = addRequest.cleanupPreparation;
try { try {
const result = await window.api.addJobsToBatch({ const result = await submitAddJobsRequest(addRequest);
jobs: newJobs.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
});
if (result?.error) { if (result?.error) {
restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreSourceCleanupStates(cleanupPreparation.rollbackStates);
const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.');
@@ -1529,12 +1527,10 @@ async function applyHosterSelection() {
if (uploading && pendingPaths.size > 0 && !(await isAutomationPaused())) { if (uploading && pendingPaths.size > 0 && !(await isAutomationPaused())) {
const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file)); const newJobs = queueJobs.filter(j => j.status === 'preview' && pendingPaths.has(j.file));
if (newJobs.length > 0) { if (newJobs.length > 0) {
const cleanupPreparation = prepareSourceCleanup(newJobs); const addRequest = createAddJobsRequest(newJobs);
const cleanupPreparation = addRequest.cleanupPreparation;
try { try {
const result = await window.api.addJobsToBatch({ const result = await submitAddJobsRequest(addRequest);
jobs: newJobs.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
});
if (result?.error) { if (result?.error) {
regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.');
restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreSourceCleanupStates(cleanupPreparation.rollbackStates);
@@ -3789,13 +3785,54 @@ function sanitizeUploadControlError(error, fallback) {
return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback; return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback;
} }
function resolveAddJobsOutcome(jobs, result) { function analyzeAddJobsInput(jobs) {
const inputJobs = Array.isArray(jobs) ? jobs : []; const inputJobs = Array.isArray(jobs) ? jobs : [];
const jobsById = new Map(inputJobs.filter(job => job?.id).map(job => [job.id, job])); const idCounts = new Map();
for (const job of inputJobs) {
const id = job?.id;
if (typeof id !== 'string' || id.trim().length === 0) continue;
idCounts.set(id, (idCounts.get(id) || 0) + 1);
}
const sendableJobs = [];
const unconfirmableJobs = [];
const jobsById = new Map();
for (const job of inputJobs) {
if (typeof job?.id === 'string' && job.id.trim().length > 0 && idCounts.get(job.id) === 1) {
sendableJobs.push(job);
jobsById.set(job.id, job);
} else {
unconfirmableJobs.push(job);
}
}
return { inputJobs, sendableJobs, unconfirmableJobs, jobsById };
}
function createAddJobsRequest(jobs) {
const analysis = analyzeAddJobsInput(jobs);
const cleanupPreparation = analysis.sendableJobs.length > 0
? prepareSourceCleanup(analysis.sendableJobs)
: { groups: [], rollbackStates: [] };
return {
...analysis,
cleanupPreparation,
payload: {
jobs: analysis.sendableJobs.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
}
};
}
async function submitAddJobsRequest(request) {
if (request.sendableJobs.length === 0) return { added: 0 };
return window.api.addJobsToBatch(request.payload);
}
function resolveAddJobsOutcome(jobs, result) {
const { sendableJobs, unconfirmableJobs, jobsById } = analyzeAddJobsInput(jobs);
const skippedEntries = []; const skippedEntries = [];
const skippedIds = new Set(); const skippedIds = new Set();
const alreadyIds = new Set(); const alreadyIds = new Set();
let valid = jobsById.size === inputJobs.length; let valid = true;
for (const entry of Array.isArray(result?.skippedJobs) ? result.skippedJobs : []) { for (const entry of Array.isArray(result?.skippedJobs) ? result.skippedJobs : []) {
const id = entry?.jobId; const id = entry?.jobId;
if (!jobsById.has(id) || skippedIds.has(id)) { if (!jobsById.has(id) || skippedIds.has(id)) {
@@ -3812,17 +3849,17 @@ function resolveAddJobsOutcome(jobs, result) {
} }
alreadyIds.add(id); alreadyIds.add(id);
} }
const remainingJobs = inputJobs.filter(job => !skippedIds.has(job.id) && !alreadyIds.has(job.id)); const remainingJobs = sendableJobs.filter(job => !skippedIds.has(job.id) && !alreadyIds.has(job.id));
const added = Number(result?.added); const added = Number(result?.added);
const consistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length; const confirmationConsistent = valid && Number.isInteger(added) && added >= 0 && added === remainingJobs.length;
return { return {
consistent, consistent: unconfirmableJobs.length === 0 && confirmationConsistent,
added: Number.isInteger(added) && added >= 0 ? added : 0, added: Number.isInteger(added) && added >= 0 ? added : 0,
addedJobs: consistent ? remainingJobs : [], addedJobs: confirmationConsistent ? remainingJobs : [],
alreadyJobs: [...alreadyIds].map(id => jobsById.get(id)), alreadyJobs: [...alreadyIds].map(id => jobsById.get(id)),
skippedEntries, skippedEntries,
skippedJobs: skippedEntries.map(entry => jobsById.get(entry.jobId)), skippedJobs: skippedEntries.map(entry => jobsById.get(entry.jobId)),
unconfirmedJobs: consistent ? [] : remainingJobs unconfirmedJobs: [...unconfirmableJobs, ...(confirmationConsistent ? [] : remainingJobs)]
}; };
} }
@@ -3963,7 +4000,8 @@ async function startSelectedUpload(explicitJobs) {
} }
{ {
const originalStates = captureUploadJobStates(addable); const originalStates = captureUploadJobStates(addable);
const cleanupPreparation = prepareSourceCleanup(addable); const addRequest = createAddJobsRequest(addable);
const cleanupPreparation = addRequest.cleanupPreparation;
addable.forEach(j => { addable.forEach(j => {
j.status = 'queued'; j.error = null; j.result = null; j.status = 'queued'; j.error = null; j.result = null;
j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null; j.bytesUploaded = 0; j.speedKbs = 0; j.progress = 0; j.uploadId = null;
@@ -3971,10 +4009,7 @@ async function startSelectedUpload(explicitJobs) {
renderQueueTable(); renderQueueTable();
let result = null; let result = null;
try { try {
result = await window.api.addJobsToBatch({ result = await submitAddJobsRequest(addRequest);
jobs: addable.map(serializeUploadJob),
sourceCleanupGroups: cleanupPreparation.groups
});
} catch (err) { } catch (err) {
restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreSourceCleanupStates(cleanupPreparation.rollbackStates);
restoreUploadJobStates(originalStates); restoreUploadJobStates(originalStates);
+169 -2
View File
@@ -253,7 +253,7 @@ contextBridge.exposeInMainWorld('api', {
}, },
addJobsToBatch(payload) { addJobsToBatch(payload) {
folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]); folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]);
automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0]); automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0, (payload?.jobs || []).map(job => job.id)]);
if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError)); if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError));
if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) { if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) {
return Promise.resolve({ return Promise.resolve({
@@ -1179,6 +1179,137 @@ contextBridge.exposeInMainWorld('api', {
} }
}; };
const resolverMissingJob = makePauseRaceJob('resolver-missing.mkv');
delete resolverMissingJob.id;
const collisionJobs = [
{ ...makePauseRaceJob('resolver-duplicate-a.mkv'), id: 'resolver-duplicate' },
{ ...makePauseRaceJob('resolver-duplicate-b.mkv'), id: 'resolver-duplicate' },
resolverMissingJob,
{ ...makePauseRaceJob('resolver-unique.mkv'), id: 'resolver-unique' }
];
const cleanCollisionOutcome = resolveAddJobsOutcome(collisionJobs, { added: 1 });
const hostileCollisionOutcome = resolveAddJobsOutcome(collisionJobs, {
added: 1,
alreadyInBatchJobIds: ['resolver-duplicate'],
skippedJobs: [{ reason: 'Ungültig' }]
});
const summarizeCollisionOutcome = outcome => ({
consistent: outcome.consistent,
added: outcome.addedJobs.map(job => job.fileName),
already: outcome.alreadyJobs.map(job => job.fileName),
skipped: outcome.skippedJobs.map(job => job.fileName),
unconfirmed: outcome.unconfirmedJobs.map(job => job.fileName)
});
const collisionResolver = {
clean: summarizeCollisionOutcome(cleanCollisionOutcome),
hostile: summarizeCollisionOutcome(hostileCollisionOutcome)
};
const applyCollisionCleanupFixture = (job, index) => {
job.sourceCleanupToken = 'collision-token-' + index;
job.sourceCleanupRequiredHosters = ['required-' + index];
job.sourceCleanupCompletedHosters = ['completed-' + index];
job.sourceCleanupFingerprint = { index, nested: ['before-' + index] };
return job;
};
const summarizeCollisionPath = async (jobs, result, before) => {
const probe = await window.api.getAutomationProbeState();
const inject = probe.mutationCalls.find(call => call[0] === 'inject');
return {
result,
sentIds: inject?.[2] || [],
statuses: jobs.map(job => job.status),
invalidRestored: jobs.slice(0, 3).map((job, index) => JSON.stringify(job) === before[index])
};
};
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
uploading = true;
const activeMissingJob = makePauseRaceJob('active-missing.mkv');
delete activeMissingJob.id;
const activeCollisionJobs = [
{ ...makePauseRaceJob('active-duplicate-a.mkv'), id: 'active-duplicate' },
{ ...makePauseRaceJob('active-duplicate-b.mkv'), id: 'active-duplicate' },
activeMissingJob,
{ ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' }
].map(applyCollisionCleanupFixture);
queueJobs = activeCollisionJobs;
rebuildJobIndex();
const activeCollisionBefore = activeCollisionJobs.slice(0, 3).map(job => JSON.stringify(job));
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
const activeCollisionResult = await startSelectedUpload(activeCollisionJobs);
const activeCollision = await summarizeCollisionPath(activeCollisionJobs, activeCollisionResult, activeCollisionBefore);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
selectedFiles = [];
uploading = true;
const manualCollisionFile = { path: 'C:\\collision\\manual.mkv', name: 'manual.mkv', size: 1 };
_pendingFiles = [manualCollisionFile];
_pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [manualCollisionFile] };
_pendingImportInspections = 0;
_pendingFolderMonitorAutoStart.clear();
const manualCollisionInputs = hosters.map(hoster => {
const input = document.createElement('input');
input.type = 'checkbox';
input.dataset.hosterModal = hoster;
input.checked = true;
return input;
});
document.getElementById('hosterModalList').replaceChildren(...manualCollisionInputs);
const originalBuildQueuePreviewForCollision = buildQueuePreview;
let manualCollisionJobs = [];
let manualCollisionBefore = [];
buildQueuePreview = () => {
originalBuildQueuePreviewForCollision();
manualCollisionJobs = queueJobs.filter(job => job.file === manualCollisionFile.path);
manualCollisionJobs[0].id = 'manual-duplicate';
manualCollisionJobs[1].id = 'manual-duplicate';
delete manualCollisionJobs[2].id;
manualCollisionJobs[3].id = 'manual-unique';
manualCollisionJobs.forEach(applyCollisionCleanupFixture);
manualCollisionBefore = manualCollisionJobs.slice(0, 3).map(job => JSON.stringify(job));
rebuildJobIndex();
};
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
const manualCollisionResult = await applyHosterSelection();
buildQueuePreview = originalBuildQueuePreviewForCollision;
const manualCollision = await summarizeCollisionPath(manualCollisionJobs, manualCollisionResult, manualCollisionBefore);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
config.globalSettings.folderMonitor.hosters = hosters.slice();
config.globalSettings.folderMonitor.autoStart = true;
selectedFiles = [];
uploading = true;
const automationCollisionFile = { path: 'C:\\collision\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 };
const originalCreateAutomationPreviewJobForCollision = createAutomationPreviewJob;
const automationCollisionJobs = [];
const automationCollisionBefore = [];
createAutomationPreviewJob = (file, hoster) => {
const job = originalCreateAutomationPreviewJobForCollision(file, hoster);
const index = automationCollisionJobs.length;
if (index < 2) job.id = 'automation-duplicate';
else if (index === 2) delete job.id;
else job.id = 'automation-unique';
applyCollisionCleanupFixture(job, index);
automationCollisionJobs.push(job);
if (index < 3) automationCollisionBefore.push(JSON.stringify(job));
return job;
};
window.api.configureAutomationProbe({ paused: false, addResult: { added: 1 } });
const automationCollisionEvaluation = await evaluateAutomationCandidates([automationCollisionFile], { dryRun: false, trigger: 'watcher' });
const automationCollisionResult = await applyAutomationEvaluation(automationCollisionEvaluation);
createAutomationPreviewJob = originalCreateAutomationPreviewJobForCollision;
const automationCollision = await summarizeCollisionPath(automationCollisionJobs, {
ok: automationCollisionResult.ok,
error: automationCollisionResult.error,
admitted: automationCollisionResult.admittedFiles.map(file => file.name)
}, automationCollisionBefore);
uploading = false;
const collisionAdmission = { active: activeCollision, manual: manualCollision, automation: automationCollision };
configureAtomicState(0); configureAtomicState(0);
selectedFiles = []; selectedFiles = [];
config.globalSettings.folderMonitor.autoStart = true; config.globalSettings.folderMonitor.autoStart = true;
@@ -1371,7 +1502,7 @@ contextBridge.exposeInMainWorld('api', {
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
}; };
return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
})()`; })()`;
const onlineBackupBehaviorScript = `(async () => { const onlineBackupBehaviorScript = `(async () => {
const ids = { const ids = {
@@ -1950,6 +2081,42 @@ app.whenReady().then(async () => {
} }
} }
}); });
assert.deepEqual(result.automationPipeline.collisionResolver, {
clean: {
consistent: false,
added: ['resolver-unique.mkv'],
already: [],
skipped: [],
unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv']
},
hostile: {
consistent: false,
added: [],
already: [],
skipped: [],
unconfirmed: ['resolver-duplicate-a.mkv', 'resolver-duplicate-b.mkv', 'resolver-missing.mkv', 'resolver-unique.mkv']
}
});
assert.deepEqual(result.automationPipeline.collisionAdmission, {
active: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['active-unique'],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
},
manual: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['manual-unique'],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
},
automation: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', admitted: [] },
sentIds: ['automation-unique'],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
}
});
assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, { assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, {
result: { result: {
ok: false, ok: false,