diff --git a/PROBLEMS.md b/PROBLEMS.md index e03b362..ab0ddd0 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -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. | | **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`. | -| **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. | | **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. | diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index 08fac7b..02006a1 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -611,6 +611,79 @@ describe('BuilderPage', () => { 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 () => { const user = userEvent.setup(); vi.spyOn(api, 'buildAgent').mockImplementation(async (_req, file) => diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index d35a575..42466ac 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -155,7 +155,8 @@ export default function BuilderPage() { fileName: string; phase: string; 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); const [fusionEstimate, setFusionEstimate] = useState(null); const [estimateLoading, setEstimateLoading] = useState(false); @@ -602,8 +603,9 @@ export default function BuilderPage() { setBuilding(true); const total = fusionBatchFiles.length; 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 skipped = 0; try { for (let i = 0; i < total; i++) { if (batchCancelRef.current) { @@ -642,13 +644,44 @@ export default function BuilderPage() { }); const checks = runForgePreflight(req, true); 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(); cancelTokenRef.current = batchToken; const result = await api.buildAgent({ ...req, cancel_token: batchToken }, file); 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) => j ? { @@ -682,10 +715,13 @@ export default function BuilderPage() { ); } if (!batchCancelRef.current) { - setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j)); - setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`); + setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '', skipped } : j)); + const skipNote = skipped > 0 ? ` · ${skipped} skipped` : ''; + setBlueprintMsg( + `✅ Batch forged ${ok} file(s)${skipNote} — one universal ZIP per file in fusion-deliverables/` + ); setTimeout(() => setBlueprintMsg(''), 6000); - setFusionBatchFiles([]); + if (ok > 0) setFusionBatchFiles([]); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Batch forge failed'; @@ -2503,6 +2539,7 @@ export default function BuilderPage() { BATCH FORGE {batchJob.current}/{batchJob.total} — {batchJob.phase} + {batchJob.skipped > 0 ? ` · ${batchJob.skipped} skipped` : ''}
@@ -2520,7 +2557,15 @@ export default function BuilderPage() { {batchJob.log.map((row) => (
  • - {row.status === 'ok' ? '✓' : row.status === 'fail' ? '✕' : row.status === 'active' ? '…' : '○'} + {row.status === 'ok' + ? '✓' + : row.status === 'fail' + ? '✕' + : row.status === 'skipped' + ? '—' + : row.status === 'active' + ? '…' + : '○'} {row.name} diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index c7bc465..d5ac68b 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1509,6 +1509,11 @@ button.deliverable-card .form-hint { color: var(--neon-red, #f55); } +.batch-forge-log li.batch-log-skipped { + color: var(--text-secondary); + opacity: 0.85; +} + .download-actions { display: flex; flex-wrap: wrap;