summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 03:38:09 +0900
committerAdam Malczewski <[email protected]>2026-06-27 03:38:09 +0900
commit18378474f6282c0dc21e8501b50f551514b55f7a (patch)
treed8325a864f731ac10a2fdab9c3de09158a65642e /src
parent2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (diff)
downloaddispatch-web-18378474f6282c0dc21e8501b50f551514b55f7a.tar.gz
dispatch-web-18378474f6282c0dc21e8501b50f551514b55f7a.zip
feat(concurrency): add provider concurrency limits UI — settings + live status
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte31
-rw-r--r--src/app/store.svelte.ts165
-rw-r--r--src/app/store.test.ts259
-rw-r--r--src/features/concurrency/index.ts42
-rw-r--r--src/features/concurrency/logic/types.ts82
-rw-r--r--src/features/concurrency/logic/view-model.test.ts366
-rw-r--r--src/features/concurrency/logic/view-model.ts266
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte106
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte311
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts160
10 files changed, 1788 insertions, 0 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index 09be947..b40ed09 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -69,6 +69,14 @@
type HeartbeatRunsResult,
type HeartbeatStopResult,
} from "../features/heartbeat";
+ import {
+ ConcurrencyView,
+ manifest as concurrencyManifest,
+ type DeleteConcurrencyLimit,
+ type LoadConcurrencyLimits,
+ type LoadConcurrencyStatus,
+ type SaveConcurrencyLimit,
+ } from "../features/concurrency";
import type { ChatStore } from "../features/chat";
import {
SystemPromptBuilder,
@@ -107,6 +115,7 @@
{ id: "tasks", label: "Tasks" },
{ id: "compaction", label: "Compaction" },
{ id: "heartbeat", label: "Heartbeat" },
+ { id: "concurrency", label: "Concurrency" },
{ id: "system-prompt", label: "System Prompt" },
{ id: "settings", label: "Settings" },
] as const;
@@ -143,6 +152,7 @@
settingsManifest,
systemPromptManifest,
heartbeatManifest,
+ concurrencyManifest,
].map((m) => [m.name, m.description] as const);
// Smart-scroll: keep the transcript pinned to the bottom while it streams,
@@ -412,6 +422,18 @@
function closeRunChat(conversationId: string): void {
store.unwatchConversation(conversationId);
}
+
+ // Adapt the store's concurrency results to the feature's ports. The store
+ // returns the feature's result types directly (the API is a plain REST surface
+ // under /concurrency, not a workspace/conversation-scoped one), so the adapter
+ // is a thin passthrough (kept for structural consistency — AGENTS.md "contracts
+ // are the cross-unit surface").
+ const loadConcurrencyLimits: LoadConcurrencyLimits = () => store.concurrencyLimits();
+ const saveConcurrencyLimit: SaveConcurrencyLimit = (providerId, limit) =>
+ store.setConcurrencyLimit(providerId, limit);
+ const deleteConcurrencyLimit: DeleteConcurrencyLimit = (providerId) =>
+ store.deleteConcurrencyLimit(providerId);
+ const loadConcurrencyStatus: LoadConcurrencyStatus = () => store.concurrencyStatus();
</script>
<main class="relative flex h-screen overflow-hidden">
@@ -690,5 +712,14 @@
loadNextRun={loadHeartbeatNextRun}
onOpenRun={(run) => (heartbeatRun = run)}
/>
+ {:else if kind === "concurrency"}
+ <!-- Per-provider concurrency limits + live status. GLOBAL (not workspace- or
+ conversation-scoped), so the panel stays mounted across tab switches. -->
+ <ConcurrencyView
+ loadLimits={loadConcurrencyLimits}
+ saveLimit={saveConcurrencyLimit}
+ deleteLimit={deleteConcurrencyLimit}
+ loadStatus={loadConcurrencyStatus}
+ />
{/if}
{/snippet}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 78f6ede..8bc1080 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -53,6 +53,17 @@ import {
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
+import type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusResult,
+} from "../features/concurrency";
+import {
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+} from "../features/concurrency";
import type { ConversationCache } from "../features/conversation-cache";
import { createConversationCache } from "../features/conversation-cache";
import type {
@@ -363,6 +374,38 @@ export interface AppStore {
/** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
unwatchConversation(conversationId: string): void;
/**
+ * Load all configured per-provider concurrency limits
+ * (`GET /concurrency/limits`). Global (not workspace-scoped). Returns an empty
+ * list when the concurrency extension isn't loaded (`{ limits: [] }`).
+ */
+ concurrencyLimits(): Promise<ConcurrencyLimitsResult>;
+ /**
+ * Fetch the configured limit for one provider
+ * (`GET /concurrency/limits/:providerId`). `404` (no limit configured) and
+ * `503` (extension not loaded) both surface as `ok: false`.
+ */
+ getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult>;
+ /**
+ * Set or update a provider's concurrency limit
+ * (`PUT /concurrency/limits/:providerId`, body `{ limit }`). `limit` must be a
+ * positive integer (a non-positive body is `400`). At the cap, further requests
+ * queue oldest-agent-first rather than being sent immediately.
+ */
+ setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult>;
+ /**
+ * Remove a provider's concurrency limit (`DELETE /concurrency/limits/:providerId`),
+ * making it unlimited. `404` (not configured) and `503` (extension not loaded)
+ * both surface as `ok: false`.
+ */
+ deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult>;
+ /**
+ * Fetch live concurrency status for every provider with a configured limit
+ * (`GET /concurrency/status`): in-flight slots held, agents queued, and a paused
+ * state with a `pausedUntil` epoch-ms when a 429 backoff is in effect. Returns
+ * an empty list when the extension isn't loaded (`{ providers: [] }`).
+ */
+ concurrencyStatus(): Promise<ConcurrencyStatusResult>;
+ /**
* A critical error that blocks normal operation (e.g. the cross-device tab
* restore fetch failed). When non-null, a full-screen modal is shown with the
* error details. Cleared by `clearFatalError` (the modal's dismiss button).
@@ -1696,6 +1739,128 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
unwatchConversation(conversationId);
},
+ // ── Concurrency (per-provider limits + live status; GLOBAL, not workspace-scoped)
+
+ async concurrencyLimits(): Promise<ConcurrencyLimitsResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/limits`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limits failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response (e.g. the extension returning `{}`) can
+ // never crash the renderer.
+ const limits = normalizeConcurrencyLimits(await res.json());
+ return { ok: true, limits };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limits request failed",
+ };
+ }
+ },
+
+ async getConcurrencyLimit(providerId: string): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const limit = normalizeConcurrencyLimit(await res.json());
+ if (limit === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: limit.providerId, limit: limit.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency limit request failed",
+ };
+ }
+ },
+
+ async setConcurrencyLimit(providerId: string, limit: number): Promise<ConcurrencyLimitResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ limit }),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ const echoed = normalizeConcurrencyLimit(await res.json());
+ if (echoed === null) {
+ return { ok: false, error: "Malformed concurrency limit response" };
+ }
+ return { ok: true, providerId: echoed.providerId, limit: echoed.limit };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set concurrency limit request failed",
+ };
+ }
+ },
+
+ async deleteConcurrencyLimit(providerId: string): Promise<ConcurrencyDeleteResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/concurrency/limits/${encodeURIComponent(providerId)}`,
+ { method: "DELETE" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Delete concurrency limit failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true, providerId };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Delete concurrency limit request failed",
+ };
+ }
+ },
+
+ async concurrencyStatus(): Promise<ConcurrencyStatusResult> {
+ try {
+ const res = await fetchImpl(`${httpBase}/concurrency/status`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Concurrency status failed (HTTP ${res.status})`,
+ };
+ }
+ const providers = normalizeConcurrencyStatus(await res.json());
+ return { ok: true, providers };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Concurrency status request failed",
+ };
+ }
+ },
+
async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
try {
const res = await fetchImpl(`${httpBase}/system-prompt`);
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 947a9b0..2074d9e 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1375,4 +1375,263 @@ describe("createAppStore", () => {
store.dispose();
});
+
+ // ── Concurrency (per-provider limits + live status; GLOBAL REST surface) ─────
+ //
+ // The concurrency API is a plain REST surface under /concurrency (provided by
+ // the `concurrency` extension). These tests fake all five endpoints + verify
+ // the store coerces the untyped JSON and routes the right method/URL.
+
+ function concurrencyFetchImpl(opts?: {
+ limits?: Record<string, unknown>;
+ limit?: Record<string, unknown>;
+ status?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const limits = opts?.limits ?? {
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ };
+ const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
+ const status = opts?.status ?? {
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/concurrency/status") && method === "GET") {
+ return new Response(JSON.stringify(status), { status: 200 });
+ }
+ if (url.endsWith("/concurrency/limits") && method === "GET") {
+ return new Response(JSON.stringify(limits), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "GET") {
+ return new Response(JSON.stringify(limit), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ const seg = url.slice(
+ url.lastIndexOf("/concurrency/limits/") + "/concurrency/limits/".length,
+ );
+ const body = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ providerId: seg, ...body }), { status: 200 });
+ }
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ return new Response(JSON.stringify({ ok: true, providerId: "umans" }), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("concurrencyLimits loads + coerces the limits list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits tolerates a malformed/empty body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ limits: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.limits).toEqual([]);
+ store.dispose();
+ });
+
+ it("concurrencyLimits surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/concurrency/limits"))
+ return new Response(JSON.stringify({ error: "Concurrency service not available" }), {
+ status: 503,
+ });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyLimits();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Concurrency service not available");
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit loads one provider's limit", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ expect(result.limit).toBe(4);
+ store.dispose();
+ });
+
+ it("getConcurrencyLimit surfaces a 404 (not configured)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "GET")
+ return new Response(
+ JSON.stringify({ error: "No concurrency limit configured for this provider" }),
+ { status: 404 },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.getConcurrencyLimit("anthropic");
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("No concurrency limit configured");
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit PUTs { limit } to the provider URL and returns the echoed limit", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("anthropic", 8);
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("PUT");
+ expect(calls[0]?.url).toContain("/concurrency/limits/anthropic");
+ expect(calls[0]?.body).toEqual({ limit: 8 });
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("anthropic"); // echoed by the fake
+ expect(result.limit).toBe(8);
+ store.dispose();
+ });
+
+ it("setConcurrencyLimit surfaces a 400 (non-positive body)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes("/concurrency/limits/") && init?.method === "PUT")
+ return new Response(
+ JSON.stringify({ error: "Body must be { limit: <positive integer> }" }),
+ {
+ status: 400,
+ },
+ );
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setConcurrencyLimit("umans", 0);
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("Body must be");
+ store.dispose();
+ });
+
+ it("deleteConcurrencyLimit DELETEs the provider URL and returns ok", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/concurrency/limits/") && method === "DELETE") {
+ calls.push({ url, method });
+ }
+ return concurrencyFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.deleteConcurrencyLimit("umans");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.method).toBe("DELETE");
+ expect(calls[0]?.url).toContain("/concurrency/limits/umans");
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providerId).toBe("umans");
+ store.dispose();
+ });
+
+ it("concurrencyStatus loads + coerces the status list (incl. pausedUntil)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providers).toHaveLength(2);
+ expect(result.providers[0]).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ });
+ expect(result.providers[1]).toMatchObject({
+ providerId: "openai-compat",
+ paused: true,
+ pausedUntil: 1_719_408_000_000,
+ });
+ store.dispose();
+ });
+
+ it("concurrencyStatus tolerates a malformed body (extension not loaded)", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: concurrencyFetchImpl({ status: {} }),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.concurrencyStatus();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.providers).toEqual([]);
+ store.dispose();
+ });
});
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts
new file mode 100644
index 0000000..0b1892b
--- /dev/null
+++ b/src/features/concurrency/index.ts
@@ -0,0 +1,42 @@
+export type {
+ ConcurrencyDeleteResult,
+ ConcurrencyLimitEntry,
+ // Contract shapes re-exported for a single import surface.
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitResult,
+ ConcurrencyLimitsResponse,
+ ConcurrencyLimitsResult,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ ConcurrencyStatusResult,
+ DeleteConcurrencyLimit,
+ GetConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ SaveConcurrencyLimit,
+ SetConcurrencyLimitRequest,
+} from "./logic/types";
+export type { Badge, ConcurrencyLimitView, ConcurrencyStatusView } from "./logic/view-model";
+export {
+ formatPauseDuration,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ normalizeLimit,
+ parseLimitInput,
+ pauseLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./logic/view-model";
+export { default as ConcurrencyLimitRow } from "./ui/ConcurrencyLimitRow.svelte";
+export { default as ConcurrencyView } from "./ui/ConcurrencyView.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "concurrency",
+ description: "Per-provider concurrency limits + live in-flight/queue status",
+} as const;
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts
new file mode 100644
index 0000000..f05211f
--- /dev/null
+++ b/src/features/concurrency/logic/types.ts
@@ -0,0 +1,82 @@
+import type {
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyLimitRequest,
+} from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte.
+ *
+ * The backend tracks + limits how many concurrent token-generating API requests
+ * are in flight PER PROVIDER. When the cap is reached, additional requests queue
+ * and are granted slots oldest-agent-first; a 429 backoff PAUSES a provider's
+ * queue until `pausedUntil`. The cap is in-memory + per-provider (no persistence),
+ * managed via a plain REST surface under `/concurrency/...` provided by the
+ * `concurrency` extension. When the extension isn't loaded, the list + status
+ * endpoints return empty arrays (`{ limits: [] }` / `{ providers: [] }`); the
+ * single / PUT / DELETE endpoints return `503`.
+ *
+ * The data shapes ARE part of `@dispatch/transport-contract` (0.23.0), so they
+ * are imported directly (mirrors `mcp` / `computer`). The result types + injected
+ * ports below are FE-owned (the composition root adapts the store's HTTP calls to
+ * them). The endpoints are GLOBAL (not workspace- or conversation-scoped).
+ */
+
+/** Re-export the contract shapes so consumers import a single surface. */
+export type {
+ ConcurrencyLimitResponse,
+ ConcurrencyLimitsResponse,
+ ConcurrencyStatusEntry,
+ ConcurrencyStatusResponse,
+ SetConcurrencyLimitRequest,
+};
+
+/**
+ * A configured concurrency limit — one provider's cap on in-flight requests.
+ * Same shape as the contract's `ConcurrencyLimitResponse`.
+ */
+export interface ConcurrencyLimitEntry {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+// ── Result types (port outcomes; the store returns these directly) ──────────────
+
+/** Outcome of `GET /concurrency/limits` (all configured limits). */
+export type ConcurrencyLimitsResult =
+ | { readonly ok: true; readonly limits: readonly ConcurrencyLimitEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+/**
+ * Outcome of `GET`/`PUT /concurrency/limits/:providerId` — the configured limit
+ * for one provider. `GET` returns `404` when the provider has no limit (surfaced
+ * as `ok: false`); `PUT` returns `400` for a non-positive-integer body.
+ */
+export type ConcurrencyLimitResult =
+ | { readonly ok: true; readonly providerId: string; readonly limit: number }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `DELETE /concurrency/limits/:providerId` (remove → unlimited). */
+export type ConcurrencyDeleteResult =
+ | { readonly ok: true; readonly providerId: string }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /concurrency/status` (live status for every limited provider). */
+export type ConcurrencyStatusResult =
+ | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] }
+ | { readonly ok: false; readonly error: string };
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+export type LoadConcurrencyLimits = () => Promise<ConcurrencyLimitsResult>;
+export type GetConcurrencyLimit = (providerId: string) => Promise<ConcurrencyLimitResult>;
+export type SaveConcurrencyLimit = (
+ providerId: string,
+ limit: number,
+) => Promise<ConcurrencyLimitResult>;
+export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>;
+export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>;
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
new file mode 100644
index 0000000..336a6eb
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -0,0 +1,366 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import {
+ formatPauseDuration,
+ normalizeConcurrencyLimit,
+ normalizeConcurrencyLimits,
+ normalizeConcurrencyStatus,
+ parseLimitInput,
+ pauseLabel,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimit,
+ viewConcurrencyLimits,
+ viewConcurrencyStatus,
+ viewConcurrencyStatuses,
+} from "./view-model";
+
+const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEntry => ({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 0,
+ paused: false,
+ ...over,
+});
+
+// ── parseLimitInput ───────────────────────────────────────────────────────────
+
+describe("parseLimitInput", () => {
+ it("accepts positive integers", () => {
+ expect(parseLimitInput("4")).toBe(4);
+ expect(parseLimitInput(" 12 ")).toBe(12);
+ expect(parseLimitInput("1")).toBe(1);
+ });
+
+ it("rejects zero, negatives, non-integers, and garbage", () => {
+ expect(parseLimitInput("0")).toBeNull();
+ expect(parseLimitInput("-1")).toBeNull();
+ expect(parseLimitInput("4.5")).toBeNull();
+ expect(parseLimitInput("")).toBeNull();
+ expect(parseLimitInput(" ")).toBeNull();
+ expect(parseLimitInput("abc")).toBeNull();
+ expect(parseLimitInput("4abc")).toBeNull();
+ });
+});
+
+// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
+
+describe("formatPauseDuration", () => {
+ it("formats seconds / minutes+seconds / hours+minutes", () => {
+ expect(formatPauseDuration(30_000)).toBe("30s");
+ expect(formatPauseDuration(65_000)).toBe("1m 05s");
+ expect(formatPauseDuration(3_660_000)).toBe("1h 01m");
+ });
+
+ it("non-positive → resuming", () => {
+ expect(formatPauseDuration(0)).toBe("resuming");
+ expect(formatPauseDuration(-5_000)).toBe("resuming");
+ });
+});
+
+describe("pauseLabel", () => {
+ it("null when not paused", () => {
+ expect(pauseLabel(false, undefined, 0)).toBeNull();
+ expect(pauseLabel(false, 10_000, 0)).toBeNull();
+ });
+
+ it("'paused' (bare) when paused without a usable timestamp", () => {
+ expect(pauseLabel(true, undefined, 0)).toBe("paused");
+ expect(pauseLabel(true, null, 0)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ });
+
+ it("countdown when paused with a future timestamp", () => {
+ const now = 1_000_000;
+ expect(pauseLabel(true, now + 30_000, now)).toBe("paused — resumes in 30s");
+ expect(pauseLabel(true, now + 65_000, now)).toBe("paused — resumes in 1m 05s");
+ });
+
+ it("'paused' (bare) when the timestamp is missing, non-finite, or expired", () => {
+ expect(pauseLabel(true, 0, 1_000)).toBe("paused");
+ expect(pauseLabel(true, 1_000, 2_000)).toBe("paused");
+ expect(pauseLabel(true, Number.NaN, 0)).toBe("paused");
+ // The countdown prefix only appears with a FUTURE timestamp:
+ expect(pauseLabel(true, 2_000, 1_000)).toBe("paused — resumes in 1s");
+ });
+});
+
+// ── viewConcurrencyStatus ──────────────────────────────────────────────────────
+
+describe("viewConcurrencyStatus", () => {
+ it("serving under capacity → success badge, in-flight label, no queue", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0);
+ expect(v.inFlightLabel).toBe("2/4");
+ expect(v.queuedLabel).toBe("no queue");
+ expect(v.pausedLabel).toBeNull();
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("at capacity with a queue → warning badge + busy (spinner)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0);
+ expect(v.inFlightLabel).toBe("4/4");
+ expect(v.queuedLabel).toBe("3 queued");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("idle (no in-flight) → neutral badge, not busy", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("neutral");
+ expect(v.busy).toBe(false);
+ expect(v.inFlightLabel).toBe("0/4");
+ });
+
+ it("paused → warning badge + pause countdown label", () => {
+ const now = 1_000_000;
+ const v = viewConcurrencyStatus(
+ status({ paused: true, pausedUntil: now + 30_000, inFlight: 4, limit: 4, queued: 3 }),
+ now,
+ );
+ expect(v.paused).toBe(true);
+ expect(v.pausedLabel).toBe("paused — resumes in 30s");
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ });
+
+ it("at capacity but no queue → success (busy only when queuing)", () => {
+ const v = viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0);
+ expect(v.badge).toBe("success");
+ expect(v.busy).toBe(false);
+ });
+
+ it("normalizes garbage counts to 0 and a malformed limit to 1", () => {
+ const v = viewConcurrencyStatus(
+ {
+ providerId: "x",
+ limit: -3,
+ inFlight: Number.NaN,
+ queued: "oops" as unknown as number,
+ paused: false,
+ },
+ 0,
+ );
+ expect(v.limit).toBe(1);
+ expect(v.inFlight).toBe(0);
+ expect(v.queued).toBe(0);
+ expect(v.inFlightLabel).toBe("0/1");
+ });
+
+ it("viewConcurrencyStatuses maps a list preserving order", () => {
+ const views = viewConcurrencyStatuses(
+ [status({ providerId: "a" }), status({ providerId: "b" })],
+ 0,
+ );
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── viewConcurrencyLimit ───────────────────────────────────────────────────────
+
+describe("viewConcurrencyLimit", () => {
+ it("passes through id + normalizes the limit", () => {
+ const v = viewConcurrencyLimit({ providerId: "umans", limit: 4 });
+ expect(v.providerId).toBe("umans");
+ expect(v.limit).toBe(4);
+ });
+
+ it("clamps a malformed limit to 1", () => {
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 0 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: -2 }).limit).toBe(1);
+ expect(viewConcurrencyLimit({ providerId: "x", limit: 2.9 }).limit).toBe(2);
+ });
+
+ it("viewConcurrencyLimits maps a list preserving order", () => {
+ const views = viewConcurrencyLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]);
+ expect(views.map((v) => v.providerId)).toEqual(["a", "b"]);
+ });
+});
+
+// ── summarizeLimits / summarizeStatus ──────────────────────────────────────────
+
+describe("summarizeLimits", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeLimits([])).toBe("No limits configured");
+ });
+ it("counts limits (singular/plural)", () => {
+ expect(summarizeLimits([{ providerId: "a", limit: 1 }])).toBe("1 limit configured");
+ expect(
+ summarizeLimits([
+ { providerId: "a", limit: 1 },
+ { providerId: "b", limit: 2 },
+ ]),
+ ).toBe("2 limits configured");
+ });
+});
+
+describe("summarizeStatus", () => {
+ it("empty → No limits configured", () => {
+ expect(summarizeStatus([], 0)).toBe("No limits configured");
+ });
+ it("aggregates providers + in-flight totals", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 2 }),
+ status({ providerId: "b", limit: 6, inFlight: 3 }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/10 in flight");
+ });
+ it("includes queued + paused fragments only when non-zero", () => {
+ const s = summarizeStatus(
+ [
+ status({ providerId: "a", limit: 4, inFlight: 4, queued: 2 }),
+ status({
+ providerId: "b",
+ limit: 4,
+ inFlight: 1,
+ queued: 0,
+ paused: true,
+ pausedUntil: 1000,
+ }),
+ ],
+ 0,
+ );
+ expect(s).toBe("2 providers · 5/8 in flight · 2 queued · 1 paused");
+ });
+ it("singular provider", () => {
+ expect(summarizeStatus([status({ providerId: "a", limit: 4, inFlight: 1 })], 0)).toBe(
+ "1 provider · 1/4 in flight",
+ );
+ });
+});
+
+// ── Network-seam normalizers ───────────────────────────────────────────────────
+
+describe("normalizeConcurrencyLimits", () => {
+ it("coerces a well-formed body", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "openai-compat", limit: 5 },
+ ]);
+ });
+
+ it("non-array / missing limits → []", () => {
+ expect(normalizeConcurrencyLimits({})).toEqual([]);
+ expect(normalizeConcurrencyLimits({ limits: "nope" })).toEqual([]);
+ expect(normalizeConcurrencyLimits(null)).toEqual([]);
+ expect(normalizeConcurrencyLimits(undefined)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps limits", () => {
+ const limits = normalizeConcurrencyLimits({
+ limits: [
+ { providerId: "umans", limit: 4 },
+ { providerId: "", limit: 9 },
+ { providerId: 123, limit: 1 },
+ { limit: 2 },
+ { providerId: "anthropic", limit: -5 },
+ ],
+ });
+ expect(limits).toEqual([
+ { providerId: "umans", limit: 4 },
+ { providerId: "anthropic", limit: 1 },
+ ]);
+ });
+});
+
+describe("normalizeConcurrencyLimit", () => {
+ it("coerces a well-formed single response", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "umans", limit: 4 })).toEqual({
+ providerId: "umans",
+ limit: 4,
+ });
+ });
+ it("null when the provider id is missing/non-string", () => {
+ expect(normalizeConcurrencyLimit({ limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit({ providerId: "", limit: 4 })).toBeNull();
+ expect(normalizeConcurrencyLimit(null)).toBeNull();
+ });
+ it("clamps a malformed limit to 1", () => {
+ expect(normalizeConcurrencyLimit({ providerId: "x", limit: 0 })).toEqual({
+ providerId: "x",
+ limit: 1,
+ });
+ });
+});
+
+describe("normalizeConcurrencyStatus", () => {
+ it("coerces a well-formed body, preserving pausedUntil only when present", () => {
+ const now = Date.now();
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ {
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ },
+ ],
+ });
+ expect(providers).toHaveLength(2);
+ const [first, second] = providers;
+ expect(first).toEqual({
+ providerId: "umans",
+ limit: 4,
+ inFlight: 2,
+ queued: 1,
+ paused: false,
+ });
+ expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
+ expect(second).toEqual({
+ providerId: "openai-compat",
+ limit: 5,
+ inFlight: 5,
+ queued: 3,
+ paused: true,
+ pausedUntil: now,
+ });
+ });
+
+ it("non-array / missing providers → []", () => {
+ expect(normalizeConcurrencyStatus({})).toEqual([]);
+ expect(normalizeConcurrencyStatus({ providers: 42 })).toEqual([]);
+ expect(normalizeConcurrencyStatus(null)).toEqual([]);
+ });
+
+ it("drops entries without a provider id + clamps counts", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "", inFlight: 1 },
+ { limit: 2 },
+ { providerId: 9, inFlight: 0 },
+ { providerId: "x", limit: -1, inFlight: "bad", queued: null, paused: "yes" },
+ ],
+ });
+ expect(providers).toEqual([
+ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
+ { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false },
+ ]);
+ });
+
+ it("omits pausedUntil when it is not a finite number", () => {
+ const providers = normalizeConcurrencyStatus({
+ providers: [
+ { providerId: "a", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: "x" },
+ { providerId: "b", limit: 1, inFlight: 0, queued: 0, paused: true, pausedUntil: null },
+ ],
+ });
+ for (const p of providers) expect("pausedUntil" in p).toBe(false);
+ });
+});
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
new file mode 100644
index 0000000..0a4a7a9
--- /dev/null
+++ b/src/features/concurrency/logic/view-model.ts
@@ -0,0 +1,266 @@
+import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+import type { ConcurrencyLimitEntry } from "./types";
+
+/**
+ * Pure view-models for the concurrency feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `ConcurrencyLimitEntry` / `ConcurrencyStatusEntry` to
+ * display shapes (badges, "2/4" in-flight labels, pause countdowns, summaries),
+ * holds the limit-input parsing, and the network-seam normalizers the composition
+ * root coerces the untyped JSON with.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A configured limit row shaped for display. */
+export interface ConcurrencyLimitView {
+ readonly providerId: string;
+ readonly limit: number;
+}
+
+/**
+ * A live status row shaped for display. Carries the raw counts plus pre-computed
+ * labels so the template stays thin.
+ */
+export interface ConcurrencyStatusView {
+ readonly providerId: string;
+ readonly limit: number;
+ readonly inFlight: number;
+ readonly queued: number;
+ readonly paused: boolean;
+ /** "2/4" — in-flight slots held vs the cap. */
+ readonly inFlightLabel: string;
+ /** "1 queued" / "no queue". */
+ readonly queuedLabel: string;
+ /** A pause label when paused, e.g. "paused — resumes in 30s"; null otherwise. */
+ readonly pausedLabel: string | null;
+ readonly badge: Badge;
+ /** True when paused or at capacity (show a spinner). */
+ readonly busy: boolean;
+}
+
+// ── Limit input parsing ───────────────────────────────────────────────────────
+
+/**
+ * Parse a raw limit input into a positive integer, or `null` when it is not a
+ * valid positive integer. Accepts "4" → 4; rejects "0", "-1", "4.5", "", "abc".
+ * Drives the Add/Save button's disabled state so an invalid value never reaches
+ * the backend (the backend is still the authority — it 400s a non-positive body).
+ */
+export function parseLimitInput(value: string): number | null {
+ const trimmed = value.trim();
+ if (trimmed === "" || !/^[0-9]+$/.test(trimmed)) return null;
+ const n = Number.parseInt(trimmed, 10);
+ return Number.isFinite(n) && n >= 1 ? n : null;
+}
+
+/**
+ * Coerce an untrusted limit value into a positive integer (default 1). Used when
+ * normalizing backend responses so a malformed `limit` can never be 0/negative.
+ */
+export function normalizeLimit(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 1;
+ const int = Math.floor(n);
+ return int >= 1 ? int : 1;
+}
+
+// ── Status → display view ──────────────────────────────────────────────────────
+
+const NO_LIMITS = "No limits configured";
+
+/**
+ * Format a remaining-ms delta as a short pause countdown: "30s", "1m 05s",
+ * "resuming" (≤ 0). Pure via the injected `remainingMs`. The component recomputes
+ * this on each status poll (every ~2s) — a 1s ticking timer is optional.
+ */
+export function formatPauseDuration(remainingMs: number): string {
+ if (remainingMs <= 0) return "resuming";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * The pause label for a status entry, or `null` when not paused. When paused with
+ * a future `pausedUntil`, shows "paused — resumes in 30s"; when paused without a
+ * usable timestamp, shows "paused". Pure via the injectable `now`.
+ */
+export function pauseLabel(
+ paused: boolean,
+ pausedUntil: number | null | undefined,
+ now: number = Date.now(),
+): string | null {
+ if (!paused) return null;
+ if (typeof pausedUntil === "number" && Number.isFinite(pausedUntil)) {
+ const remaining = pausedUntil - now;
+ if (remaining > 0) return `paused — resumes in ${formatPauseDuration(remaining)}`;
+ }
+ // Paused without a usable future timestamp (missing, non-finite, or already
+ // expired): the next status poll will clear `paused`. Show "paused" meanwhile.
+ return "paused";
+}
+
+/**
+ * Build a display view for a status entry. `now` is injectable for tests
+ * (defaults to `Date.now()`); the composition-root component passes nothing in
+ * production (it recomputes on each poll).
+ */
+export function viewConcurrencyStatus(
+ entry: ConcurrencyStatusEntry,
+ now: number = Date.now(),
+): ConcurrencyStatusView {
+ const limit = normalizeLimit(entry.limit);
+ const inFlight = clampCount(entry.inFlight);
+ const queued = clampCount(entry.queued);
+ const paused = entry.paused === true;
+ const atCapacity = inFlight >= limit;
+ let badge: Badge;
+ if (paused) badge = "warning";
+ else if (atCapacity && queued > 0) badge = "warning";
+ else if (inFlight > 0) badge = "success";
+ else badge = "neutral";
+ return {
+ providerId: entry.providerId,
+ limit,
+ inFlight,
+ queued,
+ paused,
+ inFlightLabel: `${inFlight}/${limit}`,
+ queuedLabel: queued === 0 ? "no queue" : `${queued} queued`,
+ pausedLabel: pauseLabel(paused, entry.pausedUntil, now),
+ badge,
+ busy: paused || (atCapacity && queued > 0),
+ };
+}
+
+export function viewConcurrencyStatuses(
+ entries: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): readonly ConcurrencyStatusView[] {
+ return entries.map((e) => viewConcurrencyStatus(e, now));
+}
+
+/** A display view for a configured limit entry. */
+export function viewConcurrencyLimit(entry: ConcurrencyLimitEntry): ConcurrencyLimitView {
+ return { providerId: entry.providerId, limit: normalizeLimit(entry.limit) };
+}
+
+export function viewConcurrencyLimits(
+ entries: readonly ConcurrencyLimitEntry[],
+): readonly ConcurrencyLimitView[] {
+ return entries.map(viewConcurrencyLimit);
+}
+
+// ── Summaries ──────────────────────────────────────────────────────────────────
+
+/** A one-line summary of the configured limits list, e.g. "2 limits configured". */
+export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): string {
+ if (limits.length === 0) return NO_LIMITS;
+ return `${limits.length} limit${limits.length === 1 ? "" : "s"} configured`;
+}
+
+/**
+ * A one-line summary of the live status, e.g.
+ * "2 providers · 6/10 in flight · 1 queued · 1 paused". Only the queued / paused
+ * fragments appear when non-zero.
+ */
+export function summarizeStatus(
+ providers: readonly ConcurrencyStatusEntry[],
+ now: number = Date.now(),
+): string {
+ if (providers.length === 0) return NO_LIMITS;
+ let inFlight = 0;
+ let limitTotal = 0;
+ let queued = 0;
+ let paused = 0;
+ for (const p of providers) {
+ const limit = normalizeLimit(p.limit);
+ inFlight += clampCount(p.inFlight);
+ limitTotal += limit;
+ queued += clampCount(p.queued);
+ if (p.paused === true) paused += 1;
+ }
+ const parts: string[] = [];
+ parts.push(
+ `${providers.length} provider${providers.length === 1 ? "" : "s"}`,
+ `${inFlight}/${limitTotal} in flight`,
+ );
+ if (queued > 0) parts.push(`${queued} queued`);
+ if (paused > 0) parts.push(`${paused} paused`);
+ // Touch `now` so the summary recomputes alongside the per-row pause countdown.
+ void now;
+ return parts.join(" · ");
+}
+
+// ── Network-seam normalization (pure; called by the composition root) ───────────
+//
+// The concurrency responses are untyped JSON at runtime. The store coerces each
+// defensively HERE (pure + tested) — a malformed/partial backend value (e.g. the
+// extension returning `{}`) can never crash the renderer. Mirrors the
+// `normalizeHeartbeatConfig` / inline `Array.isArray(data.servers)` guards.
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/** Coerce a non-negative count field to a non-negative integer (0 on garbage). */
+function clampCount(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : 0;
+ const int = Math.floor(n);
+ return int >= 0 ? int : 0;
+}
+
+/** Coerce an untrusted `GET /concurrency/limits` body into a typed limit list. */
+export function normalizeConcurrencyLimits(data: unknown): readonly ConcurrencyLimitEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.limits)) return [];
+ const limits = data.limits as readonly unknown[];
+ return limits
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r) => ({
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ }))
+ .filter((r) => r.providerId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT /concurrency/limits/:id` body into a limit, or null. */
+export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry | null {
+ if (!isRecord(data)) return null;
+ const providerId = asString(data.providerId);
+ if (providerId === null) return null;
+ return { providerId, limit: normalizeLimit(data.limit) };
+}
+
+/**
+ * Coerce an untrusted `GET /concurrency/status` body into a typed status list.
+ * `pausedUntil` is included only when it is a finite number (it is absent when
+ * not paused).
+ */
+export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] {
+ if (!isRecord(data) || !Array.isArray(data.providers)) return [];
+ const providers = data.providers as readonly unknown[];
+ return providers
+ .filter((r): r is Record<string, unknown> => isRecord(r))
+ .map((r): ConcurrencyStatusEntry => {
+ const base = {
+ providerId: asString(r.providerId) ?? "",
+ limit: normalizeLimit(r.limit),
+ inFlight: clampCount(r.inFlight),
+ queued: clampCount(r.queued),
+ paused: r.paused === true,
+ };
+ const pausedUntil =
+ typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)
+ ? r.pausedUntil
+ : undefined;
+ return pausedUntil !== undefined ? { ...base, pausedUntil } : base;
+ })
+ .filter((r) => r.providerId !== "");
+}
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 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model";
+ import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types";
+
+ let {
+ limit,
+ save,
+ remove,
+ }: {
+ /** The configured limit row (providerId + current limit). */
+ limit: ConcurrencyLimitView;
+ save: SaveConcurrencyLimit;
+ remove: DeleteConcurrencyLimit;
+ } = $props();
+
+ // Inline-edit state: the raw text bound to the limit input. Seeded from the
+ // row's canonical limit, but only while the field is untouched — so a save
+ // echo / list refresh re-syncs it without clobbering an in-flight edit. Mirrors
+ // the ChatLimitField seed pattern (avoids reading the prop in the $state init).
+ let draft = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let removing = $state(false);
+ let error = $state<string | null>(null);
+
+ $effect(() => {
+ const incoming = String(limit.limit);
+ untrack(() => {
+ if (draft === lastSeed) draft = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ const parsed = $derived(parseLimitInput(draft));
+ const dirty = $derived(parsed !== null && parsed !== limit.limit);
+
+ async function handleSave(): Promise<void> {
+ if (parsed === null || parsed === limit.limit) return;
+ saving = true;
+ error = null;
+ const result = await save(limit.providerId, parsed);
+ saving = false;
+ if (result.ok) {
+ // Reflect the echoed limit back into the field immediately (the prop will
+ // also re-assert it via the seed effect above once the parent reloads).
+ draft = String(result.limit);
+ lastSeed = draft;
+ } else {
+ error = result.error;
+ }
+ }
+
+ async function handleRemove(): Promise<void> {
+ removing = true;
+ error = null;
+ const result = await remove(limit.providerId);
+ removing = false;
+ if (!result.ok) {
+ error = result.error;
+ }
+ // On success the parent drops this row (re-loaded limits list).
+ }
+</script>
+
+<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center gap-2">
+ <span class="flex-1 truncate font-medium font-mono" title={limit.providerId}>{limit.providerId}</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ aria-label={`Concurrency limit for ${limit.providerId}`}
+ bind:value={draft}
+ disabled={saving || removing}
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-xs"
+ disabled={!dirty || saving || removing}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs text-error"
+ aria-label={`Remove concurrency limit for ${limit.providerId}`}
+ disabled={saving || removing}
+ onclick={handleRemove}
+ >
+ {#if removing}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ ✕
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <span class="font-mono text-xs text-error">{error}</span>
+ {/if}
+</div>
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 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract";
+ import {
+ type Badge,
+ type ConcurrencyLimitView,
+ parseLimitInput,
+ summarizeLimits,
+ summarizeStatus,
+ viewConcurrencyLimits,
+ viewConcurrencyStatuses,
+ } from "../logic/view-model";
+ import type {
+ ConcurrencyLimitEntry,
+ DeleteConcurrencyLimit,
+ LoadConcurrencyLimits,
+ LoadConcurrencyStatus,
+ SaveConcurrencyLimit,
+ } from "../logic/types";
+ import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte";
+
+ let {
+ loadLimits,
+ saveLimit,
+ deleteLimit,
+ loadStatus,
+ }: {
+ loadLimits: LoadConcurrencyLimits;
+ saveLimit: SaveConcurrencyLimit;
+ deleteLimit: DeleteConcurrencyLimit;
+ loadStatus: LoadConcurrencyStatus;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ // ── Limits (config: list / add / update / remove) ────────────────────────────
+ let limits = $state<readonly ConcurrencyLimitEntry[]>([]);
+ let limitsLoading = $state(false);
+ let limitsError = $state<string | null>(null);
+ let hasLoadedLimits = $state(false);
+
+ // Add-form state.
+ let newProviderId = $state("");
+ let newLimitInput = $state("");
+ let adding = $state(false);
+ let addError = $state<string | null>(null);
+
+ const limitViews = $derived(viewConcurrencyLimits(limits));
+ const limitsSummary = $derived(summarizeLimits(limits));
+ const parsedNewLimit = $derived(parseLimitInput(newLimitInput));
+ const trimmedProviderId = $derived(newProviderId.trim());
+ const canAdd = $derived(
+ trimmedProviderId !== "" &&
+ parsedNewLimit !== null &&
+ !limits.some((l) => l.providerId === trimmedProviderId) &&
+ !adding,
+ );
+
+ async function refreshLimits(): Promise<void> {
+ limitsLoading = true;
+ limitsError = null;
+ const result = await loadLimits();
+ limitsLoading = false;
+ hasLoadedLimits = true;
+ if (result.ok) {
+ limits = result.limits;
+ } else {
+ limitsError = result.error;
+ }
+ }
+
+ async function handleAdd(): Promise<void> {
+ if (parsedNewLimit === null || trimmedProviderId === "") return;
+ adding = true;
+ addError = null;
+ const result = await saveLimit(trimmedProviderId, parsedNewLimit);
+ adding = false;
+ if (result.ok) {
+ newProviderId = "";
+ newLimitInput = "";
+ void refreshLimits();
+ void refreshStatus();
+ } else {
+ addError = result.error;
+ }
+ }
+
+ // Wrap the ports so a row's save/remove reloads the authoritative list + status
+ // on success (the row still gets the result to drive its own UI).
+ async function rowSave(providerId: string, limit: number) {
+ const result = await saveLimit(providerId, limit);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ async function rowRemove(providerId: string) {
+ const result = await deleteLimit(providerId);
+ if (result.ok) {
+ void refreshLimits();
+ void refreshStatus();
+ }
+ return result;
+ }
+
+ // ── Live status (polls while mounted) ───────────────────────────────────────
+ let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]);
+ let statusLoading = $state(false);
+ let statusError = $state<string | null>(null);
+ let hasLoadedStatus = $state(false);
+ let now = $state(Date.now());
+
+ // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls.
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+
+ const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now));
+ const statusSummary = $derived(summarizeStatus(statusEntries, now));
+
+ async function refreshStatus(): Promise<void> {
+ statusLoading = true;
+ statusError = null;
+ const result = await loadStatus();
+ statusLoading = false;
+ hasLoadedStatus = true;
+ if (result.ok) {
+ statusEntries = result.providers;
+ } else {
+ statusError = result.error;
+ }
+ }
+
+ const STATUS_POLL_MS = 2000;
+
+ // Load limits + status on mount, and poll the live status while the view is
+ // alive (a running provider's in-flight/queued/paused transitions stay fresh
+ // without a manual refresh). Runs once — no reactive deps read inside.
+ $effect(() => {
+ untrack(() => {
+ void refreshLimits();
+ void refreshStatus();
+ });
+ const h = setInterval(() => {
+ void refreshStatus();
+ }, STATUS_POLL_MS);
+ return () => clearInterval(h);
+ });
+</script>
+
+<div class="flex flex-col gap-4">
+ <!-- Limits (config) -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={limitsLoading}
+ onclick={() => refreshLimits()}
+ aria-label="Refresh concurrency limits"
+ >
+ {#if limitsLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ <p class="text-xs opacity-60">
+ 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.
+ </p>
+
+ <!-- Add form -->
+ <form
+ class="flex flex-wrap items-end gap-2"
+ onsubmit={(e) => {
+ e.preventDefault();
+ void handleAdd();
+ }}
+ >
+ <label class="flex flex-col gap-1">
+ <span class="text-[10px] uppercase opacity-60">Provider id</span>
+ <input
+ class="input input-bordered input-xs w-40 font-mono"
+ placeholder="umans"
+ autocomplete="off"
+ spellcheck="false"
+ bind:value={newProviderId}
+ disabled={adding}
+ />
+ </label>
+ <label class="flex flex-col gap-1">
+ <span class="text-[10px] uppercase opacity-60">Limit</span>
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-xs w-20 font-mono"
+ placeholder="4"
+ bind:value={newLimitInput}
+ disabled={adding}
+ />
+ </label>
+ <button
+ type="submit"
+ class="btn btn-primary btn-xs"
+ disabled={!canAdd}
+ >
+ {#if adding}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Add
+ {/if}
+ </button>
+ </form>
+ {#if addError}
+ <p class="font-mono text-xs text-error">{addError}</p>
+ {/if}
+
+ <span class="text-xs opacity-70">{limitsSummary}</span>
+
+ {#if limitsError}
+ <p class="text-xs text-error">{limitsError}</p>
+ {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading}
+ <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each limitViews as limit (limit.providerId)}
+ <li>
+ <ConcurrencyLimitRow {limit} save={rowSave} remove={rowRemove} />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+
+ <!-- Live status -->
+ <section class="flex flex-col gap-2">
+ <div class="flex items-center justify-between gap-2">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Live status</h3>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={statusLoading}
+ onclick={() => refreshStatus()}
+ aria-label="Refresh concurrency status"
+ >
+ {#if statusLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ <p class="text-xs opacity-60">
+ 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.
+ </p>
+
+ <span class="text-xs opacity-70">{statusSummary}</span>
+
+ {#if statusError}
+ <p class="text-xs text-error">{statusError}</p>
+ {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading}
+ <p class="text-xs opacity-60">No limits configured — nothing to report.</p>
+ {:else}
+ <ul class="flex flex-col gap-2">
+ {#each statusViews as s (s.providerId)}
+ <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm">
+ <div class="flex items-center justify-between gap-2">
+ <span class="font-medium font-mono" title={s.providerId}>{s.providerId}</span>
+ <span class="badge badge-sm {badgeClass[s.badge]} gap-1">
+ {#if s.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/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}
+ </span>
+ </div>
+ <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70">
+ <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span>
+ <span>{s.queuedLabel}</span>
+ </div>
+ {#if s.pausedLabel}
+ <span class="text-xs text-warning">{s.pausedLabel}</span>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </section>
+</div>
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<ConcurrencyLimitsResult> => {
+ calls.loadLimits++;
+ return { ok: true, limits };
+ },
+ saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => {
+ 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<ConcurrencyDeleteResult> => {
+ calls.deletes.push(providerId);
+ limits = limits.filter((l) => l.providerId !== providerId);
+ return { ok: true, providerId };
+ },
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => {
+ 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<ConcurrencyLimitsResult> => ({
+ ok: false,
+ error: "Concurrency service not available",
+ }),
+ saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }),
+ deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }),
+ loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }),
+ };
+ render(ConcurrencyView, { props: failing });
+ expect(await screen.findByText("Concurrency service not available")).toBeVisible();
+ });
+});