From 486fd488432569afe6c823e975690ddadcb688ed Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sat, 27 Jun 2026 03:38:09 +0900 Subject: feat(concurrency): add provider concurrency limits UI — settings + live status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../concurrency/ui/ConcurrencyLimitRow.svelte | 106 +++++++ src/features/concurrency/ui/ConcurrencyView.svelte | 311 +++++++++++++++++++++ .../concurrency/ui/ConcurrencyView.test.ts | 160 +++++++++++ 3 files changed, 577 insertions(+) create mode 100644 src/features/concurrency/ui/ConcurrencyLimitRow.svelte create mode 100644 src/features/concurrency/ui/ConcurrencyView.svelte create mode 100644 src/features/concurrency/ui/ConcurrencyView.test.ts (limited to 'src/features/concurrency/ui') diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte new file mode 100644 index 0000000..411a3cd --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -0,0 +1,106 @@ + + +
+
+ {limit.providerId} + + + +
+ {#if error} + {error} + {/if} +
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte new file mode 100644 index 0000000..078c003 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -0,0 +1,311 @@ + + +
+ +
+
+

Concurrency limits

+ +
+

+ Cap how many concurrent token-generating requests a provider may run. At the cap, further + requests queue (oldest-agent-first); remove a limit to make a provider unlimited. Limits are + in-memory only. +

+ + +
{ + e.preventDefault(); + void handleAdd(); + }} + > + + + +
+ {#if addError} +

{addError}

+ {/if} + + {limitsSummary} + + {#if limitsError} +

{limitsError}

+ {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading} +

No limits configured — providers run unlimited.

+ {:else} +
    + {#each limitViews as limit (limit.providerId)} +
  • + +
  • + {/each} +
+ {/if} +
+ + +
+
+

Live status

+ +
+

+ In-flight slots held vs the cap (e.g. 2/4), agents queued waiting, and a paused state when a + provider backs off after a 429. Polls every 2s. +

+ + {statusSummary} + + {#if statusError} +

{statusError}

+ {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading} +

No limits configured — nothing to report.

+ {:else} +
    + {#each statusViews as s (s.providerId)} +
  • +
    + {s.providerId} + + {#if s.busy} + + {/if} + {#if s.paused} + Paused + {:else if s.inFlight >= s.limit && s.queued > 0} + At capacity + {:else if s.inFlight > 0} + Active + {:else} + Idle + {/if} + +
    +
    + {s.inFlightLabel} in flight + {s.queuedLabel} +
    + {#if s.pausedLabel} + {s.pausedLabel} + {/if} +
  • + {/each} +
+ {/if} +
+
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts new file mode 100644 index 0000000..ba458a5 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -0,0 +1,160 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../logic/types"; +import ConcurrencyView from "./ConcurrencyView.svelte"; + +// Fakes for the four injected ports. Each resolves immediately so the mount +// effect's initial load settles in a microtask (assertions await via findBy*). + +function makeFakes(opts?: { + limits?: readonly { providerId: string; limit: number }[]; + status?: readonly ConcurrencyStatusEntry[]; +}) { + let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; + const status = opts?.status ?? [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + ]; + + const calls = { + loadLimits: 0, + loadStatus: 0, + saves: [] as { providerId: string; limit: number }[], + deletes: [] as string[], + }; + + return { + calls, + loadLimits: async (): Promise => { + calls.loadLimits++; + return { ok: true, limits }; + }, + saveLimit: async (providerId: string, limit: number): Promise => { + calls.saves.push({ providerId, limit }); + // Reflect the new limit into the list the next load returns. + limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + return { ok: true, providerId, limit }; + }, + deleteLimit: async (providerId: string): Promise => { + calls.deletes.push(providerId); + limits = limits.filter((l) => l.providerId !== providerId); + return { ok: true, providerId }; + }, + loadStatus: async (): Promise => { + calls.loadStatus++; + return { ok: true, providers: status }; + }, + }; +} + +describe("ConcurrencyView", () => { + it("loads + renders the configured limits and live status on mount", async () => { + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // Limits summary (unique to the limits section) + the row's remove control. + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible(); + // Status summary (unique to the status section). + expect(await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/)).toBeInTheDocument(); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1); + expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); + }); + + it("adds a provider limit via the form (calls saveLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByPlaceholderText("umans"); + + await user.type(screen.getByPlaceholderText("umans"), "anthropic"); + await user.type(screen.getByPlaceholderText("4"), "8"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]); + // After save the component reloads the limits list (now showing the row). + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible(); + }); + + it("disables Add when the provider id is empty or the limit is invalid", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByPlaceholderText("umans"); + const addBtn = screen.getByRole("button", { name: "Add" }); + expect(addBtn).toBeDisabled(); + + // Provider id set but invalid limit → still disabled. + await user.type(screen.getByPlaceholderText("umans"), "openai-compat"); + expect(addBtn).toBeDisabled(); + + // Now a valid limit → enabled. + await user.type(screen.getByPlaceholderText("4"), "5"); + expect(addBtn).toBeEnabled(); + }); + + it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // Wait for the limits to load (unique summary) before interacting. + await screen.findByText(/1 limit configured/); + await user.click(screen.getByLabelText("Remove concurrency limit for umans")); + + expect(fakes.calls.deletes).toEqual(["umans"]); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + }); + + it("surfaces a load error from the limits endpoint", async () => { + const failing = { + loadLimits: async (): Promise => ({ + ok: false, + error: "Concurrency service not available", + }), + saveLimit: async (): Promise => ({ ok: false, error: "noop" }), + deleteLimit: async (): Promise => ({ ok: false, error: "noop" }), + loadStatus: async (): Promise => ({ ok: true, providers: [] }), + }; + render(ConcurrencyView, { props: failing }); + expect(await screen.findByText("Concurrency service not available")).toBeVisible(); + }); +}); -- cgit v1.2.3