diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 03:38:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 03:38:09 +0900 |
| commit | 18378474f6282c0dc21e8501b50f551514b55f7a (patch) | |
| tree | d8325a864f731ac10a2fdab9c3de09158a65642e /src | |
| parent | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (diff) | |
| download | dispatch-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.svelte | 31 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 165 | ||||
| -rw-r--r-- | src/app/store.test.ts | 259 | ||||
| -rw-r--r-- | src/features/concurrency/index.ts | 42 | ||||
| -rw-r--r-- | src/features/concurrency/logic/types.ts | 82 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 366 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 266 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 106 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 311 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 160 |
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(); + }); +}); |
