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 | 486fd488432569afe6c823e975690ddadcb688ed (patch) | |
| tree | 8b34d16bc0b96d4e6e2f12215de09e771cd45e33 /src/features/concurrency/logic | |
| parent | 0a271e3e8aecb1ec9db3eea873b3df1ecaab8b99 (diff) | |
| download | dispatch-web-486fd488432569afe6c823e975690ddadcb688ed.tar.gz dispatch-web-486fd488432569afe6c823e975690ddadcb688ed.zip | |
feat(concurrency): add provider concurrency limits UI — settings + live status
Diffstat (limited to 'src/features/concurrency/logic')
| -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 |
3 files changed, 714 insertions, 0 deletions
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 !== ""); +} |
