From 891e8b83ed63d29360341ccfe57e55ce148beed0 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Fri, 26 Jun 2026 23:30:50 +0100 Subject: [PATCH] Fix: harden bootstrap worker flow --- Backend/internal/worker/runner.go | 8 +- Backend/internal/worker/runner_test.go | 60 +++++++++- .../WorkspaceHome/WorkspaceHome.module.scss | 105 ++++++++++++++++++ .../WorkspaceHome/WorkspaceHome.tsx | 101 +++++++++++++---- 4 files changed, 244 insertions(+), 30 deletions(-) diff --git a/Backend/internal/worker/runner.go b/Backend/internal/worker/runner.go index c4f0cc2..bfb8afd 100644 --- a/Backend/internal/worker/runner.go +++ b/Backend/internal/worker/runner.go @@ -57,7 +57,13 @@ func (runner *Runner) Run(ctx context.Context) error { return nil } - return err + runner.logger.Error("worker claim failed", "error", err) + + if err := waitForNextPoll(ctx, runner.pollInterval); err != nil { + return nil + } + + continue } if job == nil { diff --git a/Backend/internal/worker/runner_test.go b/Backend/internal/worker/runner_test.go index e0c33ca..d67c67c 100644 --- a/Backend/internal/worker/runner_test.go +++ b/Backend/internal/worker/runner_test.go @@ -8,6 +8,7 @@ import ( "strings" "sync" "testing" + "time" "moku-backend/internal/jobs" ) @@ -109,13 +110,53 @@ func TestRunnerMarksFailedWhenHandlerMissing(t *testing.T) { } } +func TestRunnerRetriesClaimErrors(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + store := &fakeJobStore{ + claimErrors: []error{errors.New("relation \"background_jobs\" does not exist")}, + job: &jobs.Job{ + ID: "job-4", + Kind: jobs.KindBootstrapStructureMaterialize, + }, + cancel: cancel, + } + runner := NewRunner(store, slog.New(slog.NewTextHandler(io.Discard, nil)), time.Millisecond) + + handlerCalled := false + runner.Register(jobs.KindBootstrapStructureMaterialize, func(ctx context.Context, job jobs.Job) error { + handlerCalled = true + return nil + }) + + if err := runner.Run(ctx); err != nil { + t.Fatalf("runner returned error: %v", err) + } + + if !handlerCalled { + t.Fatal("expected handler to be called after claim retry") + } + if store.claimAttempts < 2 { + t.Fatalf("expected at least two claim attempts, got %d", store.claimAttempts) + } + if len(store.succeeded) != 1 || store.succeeded[0] != "job-4" { + t.Fatalf("expected job to be marked succeeded once, got %#v", store.succeeded) + } + if len(store.failed) != 0 { + t.Fatalf("expected no failed jobs, got %#v", store.failed) + } +} + type fakeJobStore struct { - mu sync.Mutex - job *jobs.Job - claimed bool - succeeded []string - failed []fakeFailure - cancel context.CancelFunc + mu sync.Mutex + job *jobs.Job + claimed bool + claimErrors []error + claimAttempts int + succeeded []string + failed []fakeFailure + cancel context.CancelFunc } type fakeFailure struct { @@ -126,6 +167,13 @@ type fakeFailure struct { func (store *fakeJobStore) ClaimNext(ctx context.Context) (*jobs.Job, error) { store.mu.Lock() defer store.mu.Unlock() + store.claimAttempts++ + + if len(store.claimErrors) > 0 { + err := store.claimErrors[0] + store.claimErrors = store.claimErrors[1:] + return nil, err + } if store.claimed || store.job == nil { return nil, nil diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss index cca8a94..53e73fb 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.module.scss @@ -518,6 +518,111 @@ gap: var(--space-3); } +.wizardFinishPanel { + display: grid; + gap: var(--space-4); + justify-items: stretch; + padding: var(--space-2) 0 0; + min-height: min(14rem, 32dvh); + align-content: center; +} + +.wizardFinishShell { + display: grid; + gap: var(--space-4); + width: min(100%, 40rem); + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; +} + +.wizardFinishStatusRow { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-3); + align-items: start; +} + +.wizardFinishIndicator { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); + background: color-mix(in srgb, var(--bootstrap-accent) 8%, transparent); +} + +.wizardFinishIndicator[data-status="failed"] { + border-color: color-mix(in srgb, var(--color-danger-border, var(--color-border)) 44%, transparent); + background: color-mix(in srgb, var(--color-danger-surface, var(--color-surface-secondary)) 36%, transparent); +} + +.wizardFinishSpinner { + width: 1.25rem; + height: 1.25rem; + border-radius: 999px; + border: 2px solid color-mix(in srgb, var(--bootstrap-accent) 18%, transparent); + border-top-color: var(--bootstrap-accent); + animation: wizardFinishSpin 900ms linear infinite; +} + +.wizardFinishIndicator[data-status="failed"] .wizardFinishSpinner { + border: 2px solid color-mix(in srgb, var(--color-danger-border, var(--color-border)) 22%, transparent); + border-top-color: var(--color-danger-text, var(--color-text)); + animation: none; + transform: rotate(45deg); + border-radius: var(--radius-sm); + width: 1rem; + height: 1rem; +} + +.wizardFinishCopy { + display: grid; + gap: var(--space-1); + min-width: 0; +} + +.wizardFinishTitle { + @include text-title; + margin: 0; +} + +.wizardFinishDescription, +.wizardFinishMessage, +.wizardFinishHint { + margin: 0; + color: var(--color-text-muted); +} + +.wizardFinishMessage[data-status="failed"] { + color: var(--color-danger-text, var(--color-text)); +} + +.wizardFinishHint { + @include text-caption; +} + +.wizardFinishActions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + padding-top: var(--space-1); +} + +@keyframes wizardFinishSpin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + @include respond-down(tablet) { .summaryGrid, .wizardBody { diff --git a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx index 6bfbd41..97500d8 100644 --- a/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx +++ b/Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx @@ -188,6 +188,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); const [isWizardOpen, setIsWizardOpen] = createSignal(false); + const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false); const [currentStepIndex, setCurrentStepIndex] = createSignal(0); const installation = createMemo(() => appShellData.installation()); const materializationState = createMemo(() => { @@ -209,6 +210,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { () => materializationState() === "pending" || materializationState() === "running", ); const hasMaterializationFailed = createMemo(() => materializationState() === "failed"); + const showBootstrapFinishingState = createMemo( + () => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed()), + ); const materializationStatusLabel = createMemo(() => { switch (materializationState()) { case "pending": @@ -263,14 +267,28 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { } if (!isBootstrapPersisted()) { + setIsFinishingBootstrapFlow(false); resetWizardState(); } setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); - setIsWizardOpen(!isBootstrapPersisted()); + setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState()); setIsBootstrapStateResolved(true); }); + createEffect(() => { + if (!isFinishingBootstrapFlow()) { + return; + } + + if (isMaterializationInFlight() || hasMaterializationFailed()) { + return; + } + + setIsFinishingBootstrapFlow(false); + setIsWizardOpen(false); + }); + createEffect(() => { if (!isBootstrapPersisted() || !isMaterializationInFlight()) { return; @@ -321,7 +339,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { const currentStepState = createMemo(() => stepState[currentStep().id]); const isFirstStep = (): boolean => currentStepIndex() === 0; const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1; - const canDismissWizard = (): boolean => isBootstrapPersisted(); + const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight(); const resetWizardState = (): void => { setInstanceForm({ ...defaultInstanceForm }); @@ -335,6 +353,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { structure: initialSubmissionState(), }); setCurrentStepIndex(0); + setIsFinishingBootstrapFlow(false); }; const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise => { @@ -395,8 +414,10 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => { if (isLastStep()) { await appShellData.reload(); + const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed()); + setIsFinishingBootstrapFlow(shouldShowFinishingState); setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); - setIsWizardOpen(!isBootstrapPersisted()); + setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState); setIsBootstrapStateResolved(true); return; } @@ -462,29 +483,16 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {

{isBootstrapPersisted() ? appShellData.activeServer().name : bootstrapTargetLabel()}

- -
-
- {materializationStatusLabel()} -
- -

- {materializationMessage()} -

-
-
-
- +
@@ -516,8 +524,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
-
- -
+
{`Step ${currentStepIndex() + 1} of ${bootstrapStepDefinitions.length}`} @@ -714,8 +768,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {

{currentStepState().error}

+
-
+