fix: isolate valid add payload identities

Validate every add candidate against the complete queue identity snapshot before renderer IPC submission. Build source cleanup groups only from globally unique, non-empty job IDs and derive required hosters from those same valid participants. Reuse the frozen request analysis when reconciling Main responses so ambiguous jobs remain local with exact cleanup rollback while unique siblings can still be confirmed. Cover active selection, manual modal injection, and automation injection with hidden renderer regressions for jobs, cleanup groups, required hosters, and misleading confirmations.
This commit is contained in:
Sucukdeluxe
2026-08-26 12:30:41 +02:00
parent d21b721de4
commit d4f47b955a
2 changed files with 139 additions and 27 deletions
+50 -10
View File
@@ -732,7 +732,10 @@ async function applyAutomationEvaluation(evaluation) {
const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.');
return freezeAutomationValue({ ok: false, error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(error), dryRun: false });
}
const outcome = applyAddJobsOutcome(newJobs, result, { cleanupStates: cleanupPreparation.rollbackStates });
const outcome = applyAddJobsOutcome(newJobs, result, {
analysis: addRequest,
cleanupStates: cleanupPreparation.rollbackStates
});
if (!outcome.consistent) {
renderQueueTable();
persistQueueStateSoon(true);
@@ -1535,7 +1538,10 @@ async function applyHosterSelection() {
regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.');
restoreSourceCleanupStates(cleanupPreparation.rollbackStates);
} else {
const outcome = applyAddJobsOutcome(newJobs, result, { cleanupStates: cleanupPreparation.rollbackStates });
const outcome = applyAddJobsOutcome(newJobs, result, {
analysis: addRequest,
cleanupStates: cleanupPreparation.rollbackStates
});
if (!outcome.consistent) regularInjectionFailure = 'Jobs konnten nicht eindeutig bestätigt werden.';
else if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
}
@@ -3785,14 +3791,22 @@ function sanitizeUploadControlError(error, fallback) {
return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback;
}
function analyzeAddJobsInput(jobs) {
function analyzeAddJobsInput(jobs, relevantJobs = jobs) {
const inputJobs = Array.isArray(jobs) ? jobs : [];
const identityJobs = Array.isArray(relevantJobs) ? relevantJobs : [];
const idCounts = new Map();
for (const job of inputJobs) {
for (const job of identityJobs) {
const id = job?.id;
if (typeof id !== 'string' || id.trim().length === 0) continue;
idCounts.set(id, (idCounts.get(id) || 0) + 1);
}
const confirmableJobs = [];
const confirmableJobsById = new Map();
for (const job of identityJobs) {
if (typeof job?.id !== 'string' || job.id.trim().length === 0 || idCounts.get(job.id) !== 1) continue;
confirmableJobs.push(job);
confirmableJobsById.set(job.id, job);
}
const sendableJobs = [];
const unconfirmableJobs = [];
const jobsById = new Map();
@@ -3804,13 +3818,38 @@ function analyzeAddJobsInput(jobs) {
unconfirmableJobs.push(job);
}
}
return { inputJobs, sendableJobs, unconfirmableJobs, jobsById };
return { inputJobs, sendableJobs, unconfirmableJobs, jobsById, confirmableJobs, confirmableJobsById };
}
function prepareAddSourceCleanup(analysis) {
const rollbackStates = captureSourceCleanupStates();
if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [], rollbackStates };
const cleanupJobs = analysis.confirmableJobs.map(job => ({
...job,
sourceCleanupRequiredHosters: []
}));
const cleanupJobsById = new Map(cleanupJobs.map(job => [job.id, job]));
const prepared = window.SourceCleanupPolicy.prepareGroups(
cleanupJobs,
analysis.sendableJobs.map(job => cleanupJobsById.get(job.id)).filter(Boolean),
() => window.crypto.randomUUID(),
'win32'
);
for (const cleanupJob of prepared.touchedJobs) {
const job = analysis.confirmableJobsById.get(cleanupJob.id);
if (!job) continue;
for (const field of sourceCleanupFields) {
if (!Object.prototype.hasOwnProperty.call(cleanupJob, field)) delete job[field];
else job[field] = cloneSourceCleanupValue(cleanupJob[field]);
}
}
return { ...prepared, rollbackStates };
}
function createAddJobsRequest(jobs) {
const analysis = analyzeAddJobsInput(jobs);
const analysis = analyzeAddJobsInput(jobs, queueJobs.slice());
const cleanupPreparation = analysis.sendableJobs.length > 0
? prepareSourceCleanup(analysis.sendableJobs)
? prepareAddSourceCleanup(analysis)
: { groups: [], rollbackStates: [] };
return {
...analysis,
@@ -3827,8 +3866,8 @@ async function submitAddJobsRequest(request) {
return window.api.addJobsToBatch(request.payload);
}
function resolveAddJobsOutcome(jobs, result) {
const { sendableJobs, unconfirmableJobs, jobsById } = analyzeAddJobsInput(jobs);
function resolveAddJobsOutcome(jobs, result, analysis = null) {
const { sendableJobs, unconfirmableJobs, jobsById } = analysis || analyzeAddJobsInput(jobs);
const skippedEntries = [];
const skippedIds = new Set();
const alreadyIds = new Set();
@@ -3864,7 +3903,7 @@ function resolveAddJobsOutcome(jobs, result) {
}
function applyAddJobsOutcome(jobs, result, options = {}) {
const outcome = resolveAddJobsOutcome(jobs, result);
const outcome = resolveAddJobsOutcome(jobs, result, options.analysis);
const confirmedJobs = new Set([...outcome.addedJobs, ...outcome.alreadyJobs, ...outcome.skippedJobs]);
const cleanupRollbackJobs = outcome.consistent
? []
@@ -4036,6 +4075,7 @@ async function startSelectedUpload(explicitJobs) {
return { ok: false, error };
}
const outcome = applyAddJobsOutcome(addable, result, {
analysis: addRequest,
cleanupStates: cleanupPreparation.rollbackStates,
uploadStates: originalStates
});
+89 -17
View File
@@ -253,7 +253,19 @@ contextBridge.exposeInMainWorld('api', {
},
addJobsToBatch(payload) {
folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]);
automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0, (payload?.jobs || []).map(job => job.id)]);
automationProbe.mutationCalls.push([
'inject',
payload?.jobs?.length || 0,
(payload?.jobs || []).map(job => job.id),
(payload?.jobs || []).map(job => ({
id: job.id,
requiredHosters: [...(job.sourceCleanupRequiredHosters || [])]
})),
(payload?.sourceCleanupGroups || []).map(group => ({
requiredHosters: [...(group.requiredHosters || [])],
jobIds: (group.jobs || []).map(job => job.jobId)
}))
]);
if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError));
if (automationProbe.addMode === 'partial-consistent' && payload?.jobs?.length >= 4) {
return Promise.resolve({
@@ -1212,34 +1224,50 @@ contextBridge.exposeInMainWorld('api', {
job.sourceCleanupFingerprint = { index, nested: ['before-' + index] };
return job;
};
const summarizeCollisionPath = async (jobs, result, before) => {
const summarizeCollisionPath = async (jobs, invalidJobs, result, before) => {
const probe = await window.api.getAutomationProbeState();
const inject = probe.mutationCalls.find(call => call[0] === 'inject');
return {
result,
sentIds: inject?.[2] || [],
sentJobs: inject?.[3] || [],
sourceCleanupGroups: inject?.[4] || [],
statuses: jobs.map(job => job.status),
invalidRestored: jobs.slice(0, 3).map((job, index) => JSON.stringify(job) === before[index])
invalidRestored: invalidJobs.map((job, index) => JSON.stringify(job) === before[index])
};
};
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
uploading = true;
const activeCollisionFile = 'C:\\\\collision\\\\active.mkv';
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-shadowed.mkv'), id: 'active-shadowed' },
{ ...makePauseRaceJob('active-unique.mkv'), id: 'active-unique' }
].map(applyCollisionCleanupFixture);
queueJobs = activeCollisionJobs;
].map((job, index) => {
job.file = activeCollisionFile;
job.hoster = [hosters[0], hosters[1], hosters[2], hosters[0], hosters[3]][index];
return applyCollisionCleanupFixture(job, index);
});
const activeShadowSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('active-shadow-sibling.mkv'),
id: 'active-shadowed',
file: activeCollisionFile,
hoster: hosters[1],
status: 'done'
}, 5);
queueJobs = [...activeCollisionJobs, activeShadowSibling];
rebuildJobIndex();
const activeCollisionBefore = activeCollisionJobs.slice(0, 3).map(job => JSON.stringify(job));
const activeInvalidJobs = [...activeCollisionJobs.slice(0, 4), activeShadowSibling];
const activeCollisionBefore = activeInvalidJobs.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);
const activeCollision = await summarizeCollisionPath(activeCollisionJobs, activeInvalidJobs, activeCollisionResult, activeCollisionBefore);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
@@ -1260,22 +1288,39 @@ contextBridge.exposeInMainWorld('api', {
document.getElementById('hosterModalList').replaceChildren(...manualCollisionInputs);
const originalBuildQueuePreviewForCollision = buildQueuePreview;
let manualCollisionJobs = [];
let manualInvalidJobs = [];
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[2].id = 'manual-shadowed';
manualCollisionJobs[3].id = 'manual-unique';
manualCollisionJobs.forEach(applyCollisionCleanupFixture);
manualCollisionBefore = manualCollisionJobs.slice(0, 3).map(job => JSON.stringify(job));
const manualShadowSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('manual-shadow-sibling.mkv'),
id: 'manual-shadowed',
file: manualCollisionFile.path,
hoster: hosters[0],
status: 'done'
}, 4);
const manualMissingSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('manual-missing-sibling.mkv'),
file: manualCollisionFile.path,
hoster: hosters[1],
status: 'done'
}, 5);
delete manualMissingSibling.id;
queueJobs.push(manualShadowSibling, manualMissingSibling);
manualInvalidJobs = [...manualCollisionJobs.slice(0, 3), manualShadowSibling, manualMissingSibling];
manualCollisionBefore = manualInvalidJobs.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);
const manualCollision = await summarizeCollisionPath(manualCollisionJobs, manualInvalidJobs, manualCollisionResult, manualCollisionBefore);
configureAtomicState(0);
config.globalSettings.deleteSourceAfterSuccessfulUpload = true;
@@ -1284,6 +1329,25 @@ contextBridge.exposeInMainWorld('api', {
selectedFiles = [];
uploading = true;
const automationCollisionFile = { path: 'C:\\collision\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 };
const automationShadowSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('automation-shadow-sibling.mkv'),
id: 'automation-shadowed',
file: 'C:\\collision\\automation-shadow-existing.mkv',
hoster: hosters[0],
status: 'done'
}, 4);
const automationMissingSibling = applyCollisionCleanupFixture({
...makePauseRaceJob('automation-missing-sibling.mkv'),
file: 'C:\\collision\\automation-missing-existing.mkv',
hoster: hosters[1],
status: 'done'
}, 5);
delete automationMissingSibling.id;
automationShadowSibling.sourceCleanupToken = 'automation-collision-token';
automationMissingSibling.sourceCleanupToken = 'automation-collision-token';
queueJobs.push(automationShadowSibling, automationMissingSibling);
rebuildJobIndex();
const automationExternalBefore = [automationShadowSibling, automationMissingSibling].map(job => JSON.stringify(job));
const originalCreateAutomationPreviewJobForCollision = createAutomationPreviewJob;
const automationCollisionJobs = [];
const automationCollisionBefore = [];
@@ -1291,9 +1355,10 @@ contextBridge.exposeInMainWorld('api', {
const job = originalCreateAutomationPreviewJobForCollision(file, hoster);
const index = automationCollisionJobs.length;
if (index < 2) job.id = 'automation-duplicate';
else if (index === 2) delete job.id;
else if (index === 2) job.id = 'automation-shadowed';
else job.id = 'automation-unique';
applyCollisionCleanupFixture(job, index);
job.sourceCleanupToken = 'automation-collision-token';
automationCollisionJobs.push(job);
if (index < 3) automationCollisionBefore.push(JSON.stringify(job));
return job;
@@ -1302,11 +1367,12 @@ contextBridge.exposeInMainWorld('api', {
const automationCollisionEvaluation = await evaluateAutomationCandidates([automationCollisionFile], { dryRun: false, trigger: 'watcher' });
const automationCollisionResult = await applyAutomationEvaluation(automationCollisionEvaluation);
createAutomationPreviewJob = originalCreateAutomationPreviewJobForCollision;
const automationCollision = await summarizeCollisionPath(automationCollisionJobs, {
const automationInvalidJobs = [...automationCollisionJobs.slice(0, 3), automationShadowSibling, automationMissingSibling];
const automationCollision = await summarizeCollisionPath(automationCollisionJobs, automationInvalidJobs, {
ok: automationCollisionResult.ok,
error: automationCollisionResult.error,
admitted: automationCollisionResult.admittedFiles.map(file => file.name)
}, automationCollisionBefore);
}, [...automationCollisionBefore, ...automationExternalBefore]);
uploading = false;
const collisionAdmission = { active: activeCollision, manual: manualCollision, automation: automationCollision };
@@ -2101,20 +2167,26 @@ app.whenReady().then(async () => {
active: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['active-unique'],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
sentJobs: [{ id: 'active-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['active-unique'] }],
statuses: ['preview', 'preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true, true, true]
},
manual: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.' },
sentIds: ['manual-unique'],
sentJobs: [{ id: 'manual-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['manual-unique'] }],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
invalidRestored: [true, true, true, true, true]
},
automation: {
result: { ok: false, error: 'Jobs konnten nicht eindeutig bestätigt werden.', admitted: [] },
sentIds: ['automation-unique'],
sentJobs: [{ id: 'automation-unique', requiredHosters: ['byse.sx'] }],
sourceCleanupGroups: [{ requiredHosters: ['byse.sx'], jobIds: ['automation-unique'] }],
statuses: ['preview', 'preview', 'preview', 'queued'],
invalidRestored: [true, true, true]
invalidRestored: [true, true, true, true, true]
}
});
assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, {