From 0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 28 Jun 2026 13:18:25 +0900 Subject: feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumes the backend's concurrency-fixes (commit 2d27666) — additive to transport-contract@0.23.0, NO version bump. ConcurrencyStatusEntry gains cooldownMs / autoReduced / autoReducedFrom? / notice?; new ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT /concurrency/cooldown/:providerId. FE: - Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput (non-negative int — 0 is valid, unlike the limit); normalizeCooldown; cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended (cooldown + autoReduce fields; auto-reduce → warning badge, not busy); viewAutoReduce/autoReduceNotices (banner view — prefers backend notice, synthesizes a fallback); summarizeStatus "N auto-reduced" fragment; normalizeConcurrencyStatus coerces new fields (immutable readonly build; autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown. - Types (logic/types.ts): re-export the 2 new contract types + ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports. - UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner — backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders the cooldown per status card + the banner section. The banner persists while autoReduced===true, is dismissible, and clears automatically once a poll shows autoReduced===false after a restore PUT. - Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown (surface 400/404/503 as ok:false; normalize at the seam) + interface decls. - App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView. - Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT). Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract. reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK. Worktree env: an untracked dispatch-backend → backend symlink was created in the worktree parent so the canonical file:../dispatch-backend/... paths resolve (NOT committed — per the §2d/§2j worktree convention). --- .../concurrency/ui/AutoReduceBanner.svelte | 81 +++++ .../concurrency/ui/ConcurrencyCooldownRow.svelte | 93 ++++++ src/features/concurrency/ui/ConcurrencyView.svelte | 107 +++++++ .../concurrency/ui/ConcurrencyView.test.ts | 339 +++++++++++++++++---- 4 files changed, 560 insertions(+), 60 deletions(-) create mode 100644 src/features/concurrency/ui/AutoReduceBanner.svelte create mode 100644 src/features/concurrency/ui/ConcurrencyCooldownRow.svelte (limited to 'src/features/concurrency/ui') diff --git a/src/features/concurrency/ui/AutoReduceBanner.svelte b/src/features/concurrency/ui/AutoReduceBanner.svelte new file mode 100644 index 0000000..132ebc7 --- /dev/null +++ b/src/features/concurrency/ui/AutoReduceBanner.svelte @@ -0,0 +1,81 @@ + + +
+
+ +
+

{notice.message}

+

+ Was {notice.fromLimit}, now {notice.currentLimit}. +

+
+
+ + + +
+
+ {#if error} +

{error}

+ {/if} +
diff --git a/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte new file mode 100644 index 0000000..03ddec9 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyCooldownRow.svelte @@ -0,0 +1,93 @@ + + +
+ cooldown + + ms + + {#if error} + {error} + {:else if justSaved && !dirty} + Saved. + {/if} +
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index f8a2199..6078b1a 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -2,6 +2,7 @@ import { untrack } from "svelte"; import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; import { + autoReduceNotices, type Badge, parseLimitInput, providerOptions, @@ -15,8 +16,12 @@ DeleteConcurrencyLimit, LoadConcurrencyLimits, LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, SaveConcurrencyLimit, } from "../logic/types"; + import AutoReduceBanner from "./AutoReduceBanner.svelte"; + import ConcurrencyCooldownRow from "./ConcurrencyCooldownRow.svelte"; import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; let { @@ -25,6 +30,7 @@ saveLimit, deleteLimit, loadStatus, + saveCooldown, }: { /** Available models (`/`) — the source of provider ids for the Add dropdown. */ models: readonly string[]; @@ -32,6 +38,7 @@ saveLimit: SaveConcurrencyLimit; deleteLimit: DeleteConcurrencyLimit; loadStatus: LoadConcurrencyStatus; + saveCooldown: SaveConcurrencyCooldown; } = $props(); const badgeClass: Record = { @@ -136,6 +143,18 @@ return result; } + // Wrap the cooldown save so a successful PUT refreshes the live status (which + // re-carries the new `cooldownMs`). The row still gets the result to drive its + // own UI. `getCooldown` is exposed for completeness/future use (the live status + // already carries `cooldownMs`, so the row seeds from the status view). + async function cooldownSave(providerId: string, cooldownMs: number) { + const result = await saveCooldown(providerId, cooldownMs); + if (result.ok) { + void refreshStatus(); + } + return result; + } + // ── Live status (polls while mounted) ─────────────────────────────────────── let statusEntries = $state([]); let statusError = $state(null); @@ -158,6 +177,69 @@ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now)); const statusSummary = $derived(summarizeStatus(statusEntries, now)); + // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ─────── + // + // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status` + // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a + // banner per such provider. The banner is DISMISSIBLE: a dismissed provider + // stays hidden while it remains auto-reduced (persist-while-true), and is + // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future + // auto-reduce re-shows the banner. Restoring the limit (PUT) clears + // `autoReduced` server-side → the next poll drops the banner automatically. + // + // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to + // localStorage / a module-global): it resets on remount (sidebar view switch / + // reload). This is correct — `autoReduced` is a REAL persisted degraded state, + // so re-showing the banner on a fresh mount reminds the user. Persisting a + // dismissal across reloads would risk HIDING an ongoing degradation (a + // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the + // component-local `limitsError`/`statusError` pattern. + let dismissedAutoReduce = $state>(new Set()); + + const allNotices = $derived(autoReduceNotices(statusEntries)); + const visibleNotices = $derived( + allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)), + ); + + // Reconcile the dismissed set against the live auto-reduced providers: keep a + // dismissed entry ONLY while its provider is still auto-reduced. A provider + // that has been restored (no longer in `allNotices`) is dropped from the + // dismissed set so a future auto-reduce re-shows its banner. + $effect(() => { + const autoReducedIds = new Set(allNotices.map((n) => n.providerId)); + untrack(() => { + let changed = false; + const next = new Set(); + for (const id of dismissedAutoReduce) { + if (autoReducedIds.has(id)) next.add(id); + else changed = true; + } + if (changed) dismissedAutoReduce = next; + }); + }); + + function dismissAutoReduce(providerId: string): void { + if (dismissedAutoReduce.has(providerId)) return; + dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]); + } + + // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits + // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits + + // status on success; the next status poll shows `autoReduced===false` and the + // banner drops (the dismissed-set effect above un-dismisses it too). The banner + // component owns its own restoring-spinner + inline error; on FAILURE the + // outcome is bubbled back so the banner shows the error inline (instead of + // silently re-enabling the button / surfacing it only in the limits section). + async function restoreLimit(providerId: string, limit: number): Promise { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + return { ok: true }; + } + return { ok: false, error: result.error }; + } + async function refreshStatus(): Promise { if (statusInFlight) return; statusInFlight = true; @@ -192,6 +274,19 @@
+ + {#if visibleNotices.length > 0} +
+ {#each visibleNotices as notice (notice.providerId)} + + {/each} +
+ {/if} +
@@ -319,10 +414,22 @@
{s.inFlightLabel} in flight {s.queuedLabel} + cooldown {s.cooldownLabel}
{#if s.pausedLabel} {s.pausedLabel} {/if} + {#if s.autoReduced} + + Limit auto-reduced{#if s.autoReducedFrom !== null} + from {s.autoReducedFrom} to {s.limit}{/if}. + + {/if} + {/each} diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index 3dc8e78..854f13b 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { + ConcurrencyCooldownResult, ConcurrencyDeleteResult, ConcurrencyLimitResult, ConcurrencyLimitsResult, @@ -13,35 +14,80 @@ import ConcurrencyView from "./ConcurrencyView.svelte"; // Available models → provider ids are "umans", "anthropic", "openai-compat". const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; -// Fakes for the four injected ports. Each resolves immediately so the mount -// effect's initial load settles in a microtask (assertions await via findBy*). +// A status entry factory (defaults to a healthy limited provider). The new +// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. +function statusEntry(over: Partial = {}): ConcurrencyStatusEntry { + return { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + ...over, + }; +} +// Fakes for the injected ports. Each resolves immediately so the mount effect's +// initial load settles in a microtask (assertions await via findBy*). The status +// list is mutable so a test can flip `autoReduced` between polls to simulate a +// restore clearing the banner. function makeFakes(opts?: { limits?: readonly { providerId: string; limit: number }[]; - status?: readonly ConcurrencyStatusEntry[]; + status?: ConcurrencyStatusEntry[]; + /** + * When set, `saveLimit` rejects with this error (returns `ok: false`) — used + * to test the auto-reduce banner's inline restore-error feedback. + */ + saveLimitError?: string; + /** + * Optional hook invoked inside `saveLimit` AFTER recording the call. Lets a + * test simulate a backend side-effect of the PUT (e.g. clearing `autoReduced` + * on the next status poll). Receives the providerId + limit + the fakes bag so + * it can mutate the status list. (A plain method reassignment would NOT reach + * the already-rendered component — the prop captured the original closure.) + */ + onSaveLimit?: ( + providerId: string, + limit: number, + self: { calls: MakeFakesCalls; setStatus: (next: ConcurrencyStatusEntry[]) => void }, + ) => void; }) { let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; - const status = opts?.status ?? [ - { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, - ]; + let status = opts?.status ?? [statusEntry()]; + const onSaveLimit = opts?.onSaveLimit; + const saveLimitError = opts?.saveLimitError; - const calls = { + const calls: MakeFakesCalls = { loadLimits: 0, loadStatus: 0, saves: [] as { providerId: string; limit: number }[], deletes: [] as string[], + cooldownSaves: [] as { providerId: string; cooldownMs: number }[], }; + function setStatus(next: ConcurrencyStatusEntry[]): void { + status = next; + } + return { calls, + // Allow a test to mutate the status list between polls (e.g. clear + // autoReduced after a restore to simulate the next poll). + setStatus, loadLimits: async (): Promise => { calls.loadLimits++; return { ok: true, limits }; }, saveLimit: async (providerId: string, limit: number): Promise => { calls.saves.push({ providerId, limit }); + if (saveLimitError !== undefined) { + return { ok: false, error: saveLimitError }; + } // Reflect the new limit into the list the next load returns. limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + if (onSaveLimit !== undefined) onSaveLimit(providerId, limit, { calls, setStatus }); return { ok: true, providerId, limit }; }, deleteLimit: async (providerId: string): Promise => { @@ -53,21 +99,41 @@ function makeFakes(opts?: { calls.loadStatus++; return { ok: true, providers: status }; }, + saveCooldown: async ( + providerId: string, + cooldownMs: number, + ): Promise => { + calls.cooldownSaves.push({ providerId, cooldownMs }); + // Reflect the new cooldown into the status list the next load returns. + status = status.map((s) => (s.providerId === providerId ? { ...s, cooldownMs } : s)); + return { ok: true, providerId, cooldownMs }; + }, + }; +} + +type MakeFakesCalls = { + loadLimits: number; + loadStatus: number; + saves: { providerId: string; limit: number }[]; + deletes: string[]; + cooldownSaves: { providerId: string; cooldownMs: number }[]; +}; + +function props(fakes: ReturnType) { + return { + models: MODELS as unknown as readonly string[], + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + saveCooldown: fakes.saveCooldown, }; } describe("ConcurrencyView", () => { it("loads + renders the configured limits and live status on mount", async () => { const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // The provider dropdown is populated from the available models' providers. const providerSelect = await screen.findByLabelText("Provider"); @@ -81,21 +147,21 @@ describe("ConcurrencyView", () => { expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); }); + it("renders the per-provider cooldown label from the live status", async () => { + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // The status card shows the cooldown alongside in-flight/queue. + expect(await screen.findByText(/cooldown 350ms/)).toBeInTheDocument(); + }); + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { // The 2s status poll + post-mutation reloads are SILENT: they never toggle a // visible loading state, so the Refresh buttons are plain-text (no spinner) // and the list area never reflows mid-refresh. Regression guard for the // flicker fix (mirrors the heartbeat runs list). const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); @@ -116,15 +182,7 @@ describe("ConcurrencyView", () => { it("adds a provider limit via the dropdown form (calls saveLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // Choose "anthropic" from the dropdown (the list is auto-selected first). @@ -142,15 +200,7 @@ describe("ConcurrencyView", () => { it("disables Add when the limit is empty/invalid (provider is auto-selected)", async () => { const user = userEvent.setup(); const fakes = makeFakes({ limits: [], status: [] }); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); const providerSelect = await screen.findByLabelText("Provider"); // A provider is auto-selected from the dropdown. @@ -172,13 +222,7 @@ describe("ConcurrencyView", () => { it("shows no-providers + disables the dropdown when there are no models", async () => { const fakes = makeFakes({ limits: [], status: [] }); render(ConcurrencyView, { - props: { - models: [], - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, + props: { ...props(fakes), models: [] as unknown as readonly string[] }, }); const providerSelect = await screen.findByLabelText("Provider"); @@ -189,15 +233,7 @@ describe("ConcurrencyView", () => { it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { const user = userEvent.setup(); const fakes = makeFakes(); - render(ConcurrencyView, { - props: { - models: MODELS, - loadLimits: fakes.loadLimits, - saveLimit: fakes.saveLimit, - deleteLimit: fakes.deleteLimit, - loadStatus: fakes.loadStatus, - }, - }); + render(ConcurrencyView, { props: props(fakes) }); // Wait for the limits to load (unique summary) before interacting. await screen.findByText(/1 limit configured/); @@ -209,7 +245,7 @@ describe("ConcurrencyView", () => { it("surfaces a load error from the limits endpoint", async () => { const failing = { - models: MODELS, + models: MODELS as unknown as readonly string[], loadLimits: async (): Promise => ({ ok: false, error: "Concurrency service not available", @@ -217,8 +253,191 @@ describe("ConcurrencyView", () => { saveLimit: async (): Promise => ({ ok: false, error: "noop" }), deleteLimit: async (): Promise => ({ ok: false, error: "noop" }), loadStatus: async (): Promise => ({ ok: true, providers: [] }), + saveCooldown: async (): Promise => ({ ok: false, error: "noop" }), }; render(ConcurrencyView, { props: failing }); expect(await screen.findByText("Concurrency service not available")).toBeVisible(); }); + + // ── Concurrency-fixes: auto-reduce banner + cooldown editing ──────────────── + + it("renders an auto-reduce banner (with the backend notice + Restore) when a provider is auto-reduced", async () => { + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + // The banner shows the backend notice verbatim + a "Restore to 4" action. + expect(await screen.findByText(/auto-reduced to 3 after a 429/)).toBeVisible(); + expect(await screen.findByRole("button", { name: /Restore to 4/ })).toBeVisible(); + // The "Was 4, now 3." provenance line is shown. + expect(await screen.findByText(/Was 4, now 3\./)).toBeVisible(); + }); + + it("clears the banner after Restore (next status poll shows autoReduced===false)", async () => { + const user = userEvent.setup(); + // Start auto-reduced (limit 3, was 4). The restore PUT clears `autoReduced` + // server-side; the next status poll returns limit 4 + autoReduced===false → + // the banner drops. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + // Simulate the backend clearing `autoReduced` on the manual PUT: the next + // status load returns the restored limit with autoReduced===false. + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The restore PUT the limit back to the original (autoReducedFrom = 4). + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + // The banner is gone (no Restore button, no notice text); the status summary + // now reflects the restored limit (2/4 in flight). + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("dismisses the auto-reduce banner locally while it stays auto-reduced", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + }); + render(ConcurrencyView, { props: props(fakes) }); + + await screen.findByRole("button", { name: /Restore to 4/ }); + // Dismiss the banner (hide locally — the provider is still auto-reduced). + await user.click(screen.getByLabelText("Dismiss auto-reduce notice for umans")); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + expect(screen.queryByText(/auto-reduced to 3 after a 429/)).toBeNull(); + }); + + it("shows an inline error in the banner when the Restore PUT fails (no silent re-enable)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + saveLimitError: "Concurrency service not available", + }); + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + + // The error surfaces INLINE in the banner (near the restore action), not + // only in the far-away limits section. The banner is still present (restore + // did not succeed) and the button re-enabled for a retry. + expect(await screen.findByTestId("restore-error-umans")).toHaveTextContent( + "Concurrency service not available", + ); + expect(screen.getByRole("button", { name: /Restore to 4/ })).toBeVisible(); + expect(screen.getByRole("button", { name: /Restore to 4/ })).not.toBeDisabled(); + // The restore PUT was attempted. + expect(fakes.calls.saves).toEqual([{ providerId: "umans", limit: 4 }]); + }); + + it("clears the inline restore error on a retry that succeeds", async () => { + const user = userEvent.setup(); + // First restore fails; the second succeeds (clears autoReduced). Reassigning + // `fakes.saveLimit` BEFORE `props(fakes)` is captured would NOT reach the + // rendered component, so swap it BEFORE render here. + const fakes = makeFakes({ + status: [ + statusEntry({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ], + onSaveLimit: (_providerId, limit, self) => { + self.setStatus([statusEntry({ limit, autoReduced: false })]); + }, + }); + let attempts = 0; + const succeeding = fakes.saveLimit; + fakes.saveLimit = async (providerId, limit) => { + attempts++; + if (attempts === 1) return { ok: false, error: "Concurrency service not available" }; + return succeeding(providerId, limit); + }; + render(ConcurrencyView, { props: props(fakes) }); + + const restoreBtn = await screen.findByRole("button", { name: /Restore to 4/ }); + await user.click(restoreBtn); + // First attempt: inline error appears. + expect(await screen.findByTestId("restore-error-umans")).toBeInTheDocument(); + + // Retry: the error clears, the banner drops (restore succeeded). + await user.click(screen.getByRole("button", { name: /Restore to 4/ })); + await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/); + expect(screen.queryByTestId("restore-error-umans")).toBeNull(); + expect(screen.queryByRole("button", { name: /Restore to/ })).toBeNull(); + }); + + it("edits the per-provider cooldown (PUT /concurrency/cooldown + reloads status)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + // Wait for the status card + its cooldown input (seeded with 350). + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + expect((cooldownInput as HTMLInputElement).value).toBe("350"); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "500"); + await user.click(screen.getByRole("button", { name: "Save cooldown for umans" })); + + // The cooldown PUT fired with the new value. + expect(fakes.calls.cooldownSaves).toEqual([{ providerId: "umans", cooldownMs: 500 }]); + // After save the component reloads status (which now carries cooldownMs=500). + expect(await screen.findByText(/cooldown 500ms/)).toBeInTheDocument(); + }); + + it("rejects a negative cooldown input (Save disabled — non-negative integer only)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ status: [statusEntry({ cooldownMs: 350 })] }); + render(ConcurrencyView, { props: props(fakes) }); + + const cooldownInput = await screen.findByLabelText("Release cooldown (ms) for umans"); + // 0 is valid (no cooldown); a negative is not. + await user.clear(cooldownInput); + await user.type(cooldownInput, "0"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeEnabled(); + + await user.clear(cooldownInput); + await user.type(cooldownInput, "-5"); + expect(screen.getByRole("button", { name: "Save cooldown for umans" })).toBeDisabled(); + expect(fakes.calls.cooldownSaves).toHaveLength(0); + }); }); -- cgit v1.2.3