Forge success label and real progress bar tied to server compile stages.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Replace Dispensed with Forged in the reveal modal, poll builder/progress with indeterminate-until-first-byte UX, and emit interpolated compile progress during long garble builds.
This commit is contained in:
AetherForge
2026-06-08 19:48:24 -07:00
parent 0c7b676e23
commit 6ab43a468b
12 changed files with 273 additions and 147 deletions

View File

@@ -253,8 +253,9 @@ func (h *Handler) buildAPKAgent(ctx context.Context, req *BuildRequest) (BuildRe
} }
platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""} platform := BuildPlatform{GOOS: "linux", GOARCH: "arm64", Ext: ""}
h.setProgress(req.CancelToken, "Compiling agent (linux/arm64)", 25) stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent (linux/arm64)", 25, 54, false)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, platform, false)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""

View File

@@ -34,10 +34,17 @@ func (h *Handler) buildUniversalAgent(ctx context.Context, req *BuildRequest, pr
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
workerPaths := map[string]string{} workerPaths := map[string]string{}
total := len(platforms) total := len(platforms)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
for i, p := range platforms { for i, p := range platforms {
pct := 14 + (i*56)/total startPct := 14 + (i*56)/total
h.setProgress(req.CancelToken, fmt.Sprintf("Compiling %s", p.Label()), pct) endPct := 14 + ((i+1)*56)/total
if endPct > 71 {
endPct = 71
}
stage := fmt.Sprintf("Compiling %s", p.Label())
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, stage, startPct, endPct, obfuscated)
wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) wp, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""

View File

@@ -690,15 +690,16 @@ func (h *Handler) buildAgent(ctx context.Context, req *BuildRequest, prepPath st
platforms := platformsForRequest(req) platforms := platformsForRequest(req)
p := platforms[0] p := platforms[0]
h.setProgress(req.CancelToken, "Compiling agent", 20) obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
stopCompileProgress := h.tickCompileProgress(ctx, req.CancelToken, "Compiling agent", 20, 71, obfuscated)
outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled) outputPath, err := h.compileWorker(ctx, agentDir, buildDir, req, buildID, p, req.FusionEnabled)
stopCompileProgress()
if err != nil { if err != nil {
cleanupBuild() cleanupBuild()
log.Printf("Build failed: %v", err) log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
h.setProgress(req.CancelToken, "Compiled — linking output", 72) h.setProgress(req.CancelToken, "Compiled — linking output", 72)
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
workerName := filepath.Base(outputPath) workerName := filepath.Base(outputPath)
finalPath := outputPath finalPath := outputPath
finalName := workerName finalName := workerName

View File

@@ -0,0 +1,45 @@
package builder
import (
"context"
"time"
)
// tickCompileProgress emits interpolated progress during long compile steps (garble can run 10+ minutes).
// Returns a stop function; call it when the compile finishes.
func (h *Handler) tickCompileProgress(ctx context.Context, token, stage string, startPct, capPct int, obfuscated bool) func() {
if token == "" || capPct <= startPct {
return func() {}
}
est := 3 * time.Minute
if obfuscated {
est = 12 * time.Minute
}
done := make(chan struct{})
go func() {
h.setProgress(token, stage, startPct)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
start := time.Now()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
elapsed := time.Since(start)
ratio := float64(elapsed) / float64(est)
if ratio > 0.92 {
ratio = 0.92
}
pct := startPct + int(float64(capPct-startPct)*ratio)
if pct >= capPct {
pct = capPct - 1
}
h.setProgress(token, stage, pct)
}
}
}()
return func() { close(done) }
}

View File

@@ -0,0 +1,36 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import ForgeDispenseReveal from './ForgeDispenseReveal';
vi.mock('../../context/AmbientMusicContext', () => ({
useModalAmbientDuck: () => {},
}));
vi.mock('../../context/SoundContext', () => ({
useSound: () => ({ play: vi.fn() }),
}));
vi.mock('../DownloadButton', () => ({
default: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
}));
describe('ForgeDispenseReveal', () => {
it('shows Forged title on successful forge', () => {
render(
<ForgeDispenseReveal
result={{
success: true,
file_name: 'worker.exe',
download_url: '/api/v1/builds/x/download',
stealth_score: 72,
}}
onClose={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: 'Forged' })).toBeInTheDocument();
expect(screen.queryByText('Dispensed')).not.toBeInTheDocument();
});
});

View File

@@ -35,7 +35,7 @@ export default function ForgeDispenseReveal({ result, onClose }: Props) {
<div className="forge-dispense-panel"> <div className="forge-dispense-panel">
<div className="forge-dispense-sigil" aria-hidden /> <div className="forge-dispense-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title"> <h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed Forged
</h2> </h2>
<p className="forge-dispense-sub"> <p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature. {result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.

View File

@@ -0,0 +1,48 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import ForgeProgressBar, {
FORGE_INITIAL_STAGE,
isForgeProgressIndeterminate,
} from './ForgeProgressBar';
describe('isForgeProgressIndeterminate', () => {
it('is indeterminate before server reports progress', () => {
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 0)).toBe(true);
expect(isForgeProgressIndeterminate('', 0)).toBe(true);
});
it('is determinate once server reports pct or stage advances', () => {
expect(isForgeProgressIndeterminate('Compiling agent', 20)).toBe(false);
expect(isForgeProgressIndeterminate(FORGE_INITIAL_STAGE, 5)).toBe(false);
});
});
describe('ForgeProgressBar', () => {
it('renders nothing when not building', () => {
const { container } = render(
<ForgeProgressBar building={false} stage="" progress={0} />,
);
expect(container.firstChild).toBeNull();
});
it('shows indeterminate track before server progress', () => {
const { container } = render(
<ForgeProgressBar building stage={FORGE_INITIAL_STAGE} progress={0} />,
);
expect(screen.getByText('…')).toBeInTheDocument();
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeTruthy();
cleanup();
});
it('shows server stage and pct when progress is reported', () => {
const { container } = render(
<ForgeProgressBar building stage="Compiling agent" progress={42} />,
);
expect(screen.getByText('Compiling agent')).toBeInTheDocument();
expect(screen.getByText('42%')).toBeInTheDocument();
expect(container.querySelector('.forge-progress-track.indeterminate')).toBeNull();
});
});

View File

@@ -0,0 +1,47 @@
export const FORGE_INITIAL_STAGE = 'Initializing forge...';
interface Props {
building: boolean;
stage: string;
progress: number;
}
/** True while waiting for the first server-reported forge stage. */
export function isForgeProgressIndeterminate(stage: string, progress: number): boolean {
return progress === 0 && (stage === '' || stage === FORGE_INITIAL_STAGE);
}
export default function ForgeProgressBar({ building, stage, progress }: Props) {
if (!building) return null;
const indeterminate = isForgeProgressIndeterminate(stage, progress);
const displayStage = stage || 'Initializing...';
const pctLabel = indeterminate ? '…' : `${Math.round(progress)}%`;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{displayStage}</span>
<span className="forge-progress-pct">{pctLabel}</span>
</div>
<div
className={`forge-progress-track${indeterminate ? ' indeterminate' : ''}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : Math.round(progress)}
aria-busy={indeterminate}
aria-label={displayStage}
>
<div
className="forge-progress-fill"
style={indeterminate ? undefined : { width: `${progress}%` }}
/>
{!indeterminate && (
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react';
import { useForge } from '../context/ForgeContext';
const FORGE_POLL_MS = 1000;
/** Poll GET /api/v1/builder/progress/{token} while a forge is running. */
export function useForgeProgressPoll(
active: boolean,
cancelTokenRef: React.RefObject<string>,
) {
const { startForge, endForge, setStage } = useForge();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!active) {
endForge();
if (timerRef.current) clearTimeout(timerRef.current);
return;
}
startForge();
const token = cancelTokenRef.current;
if (!token) return;
let alive = true;
const poll = async () => {
if (!alive) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${encodeURIComponent(token)}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage?: string; pct?: number } = await res.json();
const pct = typeof data.pct === 'number' ? data.pct : 0;
if (alive && (data.stage || pct > 0)) {
setStage(data.stage || 'Forging...', pct);
}
}
} catch {
// network hiccup — keep polling
}
if (alive) {
timerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
};
poll();
return () => {
alive = false;
if (timerRef.current) clearTimeout(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
}

View File

@@ -28,6 +28,8 @@ import {
} from '../help/forgeFormNormalize'; } from '../help/forgeFormNormalize';
import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton'; import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker'; import PoolPresetPicker from '../components/PoolPresetPicker';
@@ -101,29 +103,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds); return forgeDefaultsFromServerSmart(config, serverInfo, builds);
} }
// Poll interval (ms) for real server-side build progress.
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div
className="forge-progress-fill"
style={{ width: `${progress}%` }}
/>
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
const FORGE_MODE_KEY = 'aetherforge-forge-mode'; const FORGE_MODE_KEY = 'aetherforge-forge-mode';
function loadSimpleMode(): boolean { function loadSimpleMode(): boolean {
@@ -139,8 +118,7 @@ function loadSimpleMode(): boolean {
export default function BuilderPage() { export default function BuilderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [form, setForm] = useState<BuildRequest | null>(null); const [form, setForm] = useState<BuildRequest | null>(null);
const [building, setBuilding] = useState(false); const [building, setBuilding] = useState(false);
@@ -217,52 +195,7 @@ export default function BuilderPage() {
const forgeSkinClass = forgePageClass(operationMode, forgeTheme); const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Poll real server-side build progress while a single build is running. useForgeProgressPoll(Boolean(building && !batchJob), cancelTokenRef);
// The server exposes GET /api/v1/builder/progress/{token} which returns
// {stage, pct} updated at each key compile stage, so the bar reflects
// actual server activity instead of a client-side time estimate.
useEffect(() => {
if (!building || batchJob) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
startForge();
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
};
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building, batchJob]);
const setForgeMode = (simple: boolean) => { const setForgeMode = (simple: boolean) => {
setSimpleMode(simple); setSimpleMode(simple);
@@ -348,7 +281,7 @@ export default function BuilderPage() {
}, [searchParams, recentBuilds, form]); }, [searchParams, recentBuilds, form]);
const finishForgeSuccess = async (result: BuildResponse) => { const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100); setStage('Forged!', 100);
setLastBuild(result); setLastBuild(result);
setDispenseReveal(result); setDispenseReveal(result);
loadRecentBuilds(); loadRecentBuilds();
@@ -789,7 +722,6 @@ export default function BuilderPage() {
cancelToken, cancelToken,
onStep: (step) => { onStep: (step) => {
setMissionStep(step); setMissionStep(step);
if (step === 'forge') startForge();
}, },
}); });
setMissionExportSkipped(result.exportSkipped); setMissionExportSkipped(result.exportSkipped);

View File

@@ -13,6 +13,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
import { HelpTip } from '../components/HelpTip'; import { HelpTip } from '../components/HelpTip';
import AlsoHere from '../components/Presence/AlsoHere'; import AlsoHere from '../components/Presence/AlsoHere';
import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal'; import ForgeDispenseReveal from '../components/Forge/ForgeDispenseReveal';
import ForgeProgressBar from '../components/Forge/ForgeProgressBar';
import { useForgeProgressPoll } from '../hooks/useForgeProgressPoll';
import { useModalAmbientDuck } from '../context/AmbientMusicContext'; import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useForge } from '../context/ForgeContext'; import { useForge } from '../context/ForgeContext';
import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults'; import { forgeDefaultsFromServerSmart, applySmartForgeDefaults } from '../help/forgeSmartDefaults';
@@ -53,25 +55,6 @@ function defaultsFromConfig(config: ServerConfig, serverInfo: ServerInfo, builds
return forgeDefaultsFromServerSmart(config, serverInfo, builds); return forgeDefaultsFromServerSmart(config, serverInfo, builds);
} }
const FORGE_POLL_MS = 1000;
function ForgeProgressBar({ building, stage, progress }: { building: boolean; stage: string; progress: number }) {
if (!building) return null;
return (
<div className="forge-progress-wrap" aria-live="polite">
<div className="forge-progress-header">
<span className="forge-progress-icon"></span>
<span className="forge-progress-stage">{stage || 'Initializing...'}</span>
<span className="forge-progress-pct">{Math.round(progress)}%</span>
</div>
<div className="forge-progress-track">
<div className="forge-progress-fill" style={{ width: `${progress}%` }} />
<div className="forge-progress-glow" style={{ left: `${progress}%` }} />
</div>
</div>
);
}
function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' { function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' | 'amber' | 'gold' {
if (chip === 'ghost') return 'cyan'; if (chip === 'ghost') return 'cyan';
if (chip === 'loud') return 'magenta'; if (chip === 'loud') return 'magenta';
@@ -80,9 +63,7 @@ function previewAccentForChip(chip: MissionOperationChip): 'cyan' | 'magenta' |
export default function MissionDeckPage() { export default function MissionDeckPage() {
const { startForge, endForge, setStage, stage: forgeStage, progress: forgeProgress } = useForge(); const { setStage, stage: forgeStage, progress: forgeProgress } = useForge();
const forgeStageTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelTokenRef = useRef(''); const cancelTokenRef = useRef('');
@@ -166,52 +147,10 @@ export default function MissionDeckPage() {
.catch(() => setError('Failed to load server config — is the control server running?')) .catch(() => setError('Failed to load server config — is the control server running?'))
.finally(() => setLoadingDefaults(false)); .finally(() => setLoadingDefaults(false));
}, []); }, []);
// Poll real server-side build progress (same as BuilderPage). useForgeProgressPoll(building, cancelTokenRef);
useEffect(() => {
if (!building) {
endForge();
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
return;
}
startForge();
const token = cancelTokenRef.current;
if (!token) return;
let active = true;
const poll = async () => {
if (!active) return;
try {
const { authHeaders } = await import('../api/auth');
const res = await fetch(`/api/v1/builder/progress/${token}`, {
headers: authHeaders(),
});
if (res.ok) {
const data: { stage: string; pct: number } = await res.json();
if (active && data.stage) {
setStage(data.stage, data.pct);
}
}
} catch {
// network hiccup — keep polling
}
if (active) {
forgeStageTimerRef.current = setTimeout(poll, FORGE_POLL_MS);
}
};
poll();
return () => {
active = false;
if (forgeStageTimerRef.current) clearTimeout(forgeStageTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building]);
useEffect(() => { useEffect(() => {
return () => { return () => {
const tok = cancelTokenRef.current; const tok = cancelTokenRef.current;
if (tok) api.cancelBuild(tok).catch(() => {}); if (tok) api.cancelBuild(tok).catch(() => {});
}; };
@@ -247,7 +186,7 @@ export default function MissionDeckPage() {
}; };
const finishForgeSuccess = async (result: BuildResponse) => { const finishForgeSuccess = async (result: BuildResponse) => {
setStage('Build complete!', 100); setStage('Forged!', 100);
setDispenseReveal(result); setDispenseReveal(result);
}; };
@@ -301,7 +240,6 @@ export default function MissionDeckPage() {
cancelToken, cancelToken,
onStep: (step) => { onStep: (step) => {
setMissionStep(step); setMissionStep(step);
if (step === 'forge') startForge();
}, },
}); });
setMissionExportSkipped(result.exportSkipped); setMissionExportSkipped(result.exportSkipped);

View File

@@ -1591,11 +1591,25 @@ button.deliverable-card .form-hint {
border-radius: 4px; border-radius: 4px;
background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700); background: linear-gradient(90deg, #ff6a00, #ffb300, #ffd700);
box-shadow: 0 0 8px rgba(255, 160, 0, 0.6); box-shadow: 0 0 8px rgba(255, 160, 0, 0.6);
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.forge-progress-track.indeterminate {
overflow: hidden;
}
.forge-progress-track.indeterminate .forge-progress-fill {
width: 35%;
animation: forge-progress-indeterminate 1.4s ease-in-out infinite;
}
@keyframes forge-progress-indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(320%); }
}
.forge-progress-glow { .forge-progress-glow {
position: absolute; position: absolute;
top: 50%; top: 50%;