diff --git a/web/public/app.js b/web/public/app.js
index a816780..f847a88 100644
--- a/web/public/app.js
+++ b/web/public/app.js
@@ -1148,6 +1148,41 @@ $('#btnDeleteRun').addEventListener('click', async () => {
}
});
+$('#btnRerun')?.addEventListener('click', () => {
+ clearInterval(state.detailPoll);
+ const target = $('#detailTarget').textContent || '';
+ const sub = $('#detailTargetSub').textContent || '';
+ const launch = state.detailLaunch;
+ switchView('wizard');
+ goToStep(0);
+ if (launch) {
+ if (launch.target) $('#fieldTarget').value = launch.target;
+ if (launch.name) $('#fieldName').value = launch.name + ' (re-run)';
+ if (launch.repo) $('#fieldRepo').value = launch.repo;
+ if (launch.mode) { const sel = $('#fieldMode'); if (sel) sel.value = launch.mode; }
+ if (launch.votes) $('#fieldVotes').value = launch.votes;
+ if (launch.chainDepth !== undefined) $('#fieldChain').value = launch.chainDepth;
+ if (launch.recon) $('#fieldRecon').value = launch.recon;
+ if (launch.maxAgents) $('#fieldMaxAgents').value = launch.maxAgents;
+ if (launch.quick && $('#fieldQuick')) $('#fieldQuick').checked = true;
+ if (launch.research && $('#fieldResearch')) $('#fieldResearch').checked = true;
+ if (launch.subscription) state.authMode = 'subscription';
+ if (launch.mcp && $('#fieldMcp')) $('#fieldMcp').checked = launch.mcp;
+ if (launch.models?.length) {
+ const parts = launch.models[0].split(':');
+ if (parts.length >= 2) {
+ $('#fieldProvider').value = parts[0];
+ $('#fieldProvider').dispatchEvent(new Event('change'));
+ setTimeout(() => { $('#fieldModelSelect').value = parts.slice(1).join(':'); }, 100);
+ }
+ }
+ } else {
+ $('#fieldTarget').value = sub || target;
+ $('#fieldName').value = (target || '') + ' (re-run)';
+ }
+ toast('Settings loaded from previous run. Adjust and launch.', 'ok', 4000);
+});
+
$('#btnDetailBack').addEventListener('click', () => { clearInterval(state.detailPoll); switchView('wizard'); });
$('#btnNewEngagement')?.addEventListener('click', () => { leaveLiveJob(); clearInterval(state.detailPoll); switchView('wizard'); });
if ($('#btnNewPentest2')) $('#btnNewPentest2').addEventListener('click', () => { leaveLiveJob(); clearInterval(state.detailPoll); switchView('wizard'); });
@@ -1758,6 +1793,7 @@ async function loadDetail(id) {
$('#detailState').textContent = detail.status?.state || 'unknown';
state.detailFindings = detail.findings;
state.detailPocs = detail.pocs || [];
+ state.detailLaunch = detail.launch || null;
// Regression vs the previous run of this target — new / recurring badges and a
// "fixed since" banner. Best-effort: it never blocks the findings table.
state.detailRegression = null;
diff --git a/web/public/index.html b/web/public/index.html
index d591094..a11c1c9 100644
--- a/web/public/index.html
+++ b/web/public/index.html
@@ -741,6 +741,7 @@
+
Audit trail
diff --git a/web/server.js b/web/server.js
index 1d0cc14..fe774d0 100644
--- a/web/server.js
+++ b/web/server.js
@@ -791,7 +791,19 @@ async function runDetail(id) {
const liveRunIds = new Set([...jobs.values()].filter((j) => !j.done && j.runId).map((j) => j.runId));
if (!liveRunIds.has(id)) status.state = 'interrupted';
}
- return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs, extras };
+ let launch = null;
+ const liveJob = [...jobs.values()].find((j) => j.runId === id);
+ if (liveJob?.launch) launch = liveJob.launch;
+ else {
+ try {
+ const jfiles = await fsp.readdir(JOBS_DIR);
+ for (const jf of jfiles) {
+ const rec = JSON.parse(await fsp.readFile(path.join(JOBS_DIR, jf), 'utf8'));
+ if (rec.runId === id && rec.launch) { launch = rec.launch; break; }
+ }
+ } catch { /* no job files */ }
+ }
+ return { id, name: engagementNames.get(id) || '', meta, status, findings, assets, pocs, extras, launch };
}
/// Relative file list under a directory (one level of nesting is enough for