diff options
| author | Adam Malczewski <[email protected]> | 2026-06-28 13:18:25 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-28 14:41:47 +0900 |
| commit | 0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3 (patch) | |
| tree | b198da2719987ffd58e9c2633fc36684cf2ad316 /src/features/concurrency/logic | |
| parent | a59200e786f7d97d7ba5b9cd2bee9ffef531dac2 (diff) | |
| download | dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.tar.gz dispatch-web-0f1b04bd7976ea416f11d83e4b7b78cf537bfdf3.zip | |
feat(concurrency): configurable cooldown + adaptive-headroom auto-reduce banner
Consumes the backend's concurrency-fixes (commit 2d27666) — additive to
[email protected], NO version bump. ConcurrencyStatusEntry gains
cooldownMs / autoReduced / autoReducedFrom? / notice?; new
ConcurrencyCooldownResponse / SetConcurrencyCooldownRequest + GET/PUT
/concurrency/cooldown/:providerId.
FE:
- Pure core (logic/view-model.ts): DEFAULT_COOLDOWN_MS; parseCooldownInput
(non-negative int — 0 is valid, unlike the limit); normalizeCooldown;
cooldownLabel ("350ms"/"1.2s"/"0ms (off)"); viewConcurrencyStatus extended
(cooldown + autoReduce fields; auto-reduce → warning badge, not busy);
viewAutoReduce/autoReduceNotices (banner view — prefers backend notice,
synthesizes a fallback); summarizeStatus "N auto-reduced" fragment;
normalizeConcurrencyStatus coerces new fields (immutable readonly build;
autoReducedFrom/notice only when autoReduced===true); normalizeConcurrencyCooldown.
- Types (logic/types.ts): re-export the 2 new contract types +
ConcurrencyCooldownResult + Get/SaveConcurrencyCooldown ports.
- UI: ConcurrencyCooldownRow.svelte (inline-edit cooldown + Save → PUT, seeded
via the ChatLimitField pattern); AutoReduceBanner.svelte (dismissible banner —
backend notice + "Was N, now M." + "Restore to N"); ConcurrencyView renders
the cooldown per status card + the banner section. The banner persists while
autoReduced===true, is dismissible, and clears automatically once a poll shows
autoReduced===false after a restore PUT.
- Store (store.svelte.ts): getConcurrencyCooldown + setConcurrencyCooldown
(surface 400/404/503 as ok:false; normalize at the seam) + interface decls.
- App.svelte: saveConcurrencyCooldown adapter → ConcurrencyView.
- Tests: +47 (view-model cooldown/auto-reduce/normalizers; component banner
render, cooldown PUT, restore clears banner, dismiss; store cooldown GET/PUT).
Re-synced the file: dep (bun install) + re-mirrored .dispatch/transport-contract.
reference.md. typecheck 0/0, 1048 tests green (run twice), biome clean, build OK.
Worktree env: an untracked dispatch-backend → backend symlink was created in the
worktree parent so the canonical file:../dispatch-backend/... paths resolve
(NOT committed — per the §2d/§2j worktree convention).
Diffstat (limited to 'src/features/concurrency/logic')
| -rw-r--r-- | src/features/concurrency/logic/types.ts | 37 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 232 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 211 |
3 files changed, 460 insertions, 20 deletions
diff --git a/src/features/concurrency/logic/types.ts b/src/features/concurrency/logic/types.ts index f05211f..a0c5f6b 100644 --- a/src/features/concurrency/logic/types.ts +++ b/src/features/concurrency/logic/types.ts @@ -1,8 +1,10 @@ import type { + ConcurrencyCooldownResponse, ConcurrencyLimitResponse, ConcurrencyLimitsResponse, ConcurrencyStatusEntry, ConcurrencyStatusResponse, + SetConcurrencyCooldownRequest, SetConcurrencyLimitRequest, } from "@dispatch/transport-contract"; @@ -23,14 +25,23 @@ import type { * 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). + * + * Concurrency-fixes (additive, no version bump): each `ConcurrencyStatusEntry` + * now also carries `cooldownMs` (per-slot release cooldown, configurable + + * persisted), `autoReduced` (a 429 auto-reduced the limit by 1, one-way), and + * when auto-reduced, `autoReducedFrom` + a `notice` banner string. A manual + * `PUT /concurrency/limits/:providerId` clears `autoReduced`. Two new endpoints + * `GET`/`PUT /concurrency/cooldown/:providerId` view/change the cooldown. */ /** Re-export the contract shapes so consumers import a single surface. */ export type { + ConcurrencyCooldownResponse, ConcurrencyLimitResponse, ConcurrencyLimitsResponse, ConcurrencyStatusEntry, ConcurrencyStatusResponse, + SetConcurrencyCooldownRequest, SetConcurrencyLimitRequest, }; @@ -69,6 +80,25 @@ export type ConcurrencyStatusResult = | { readonly ok: true; readonly providers: readonly ConcurrencyStatusEntry[] } | { readonly ok: false; readonly error: string }; +/** + * Outcome of `GET`/`PUT /concurrency/cooldown/:providerId` — the per-slot + * release cooldown (ms) for one provider. `GET` returns `404` when the provider + * has no concurrency config at all (no limit, no cooldown); `PUT` returns `400` + * for a non-negative-integer body. Both return `503` when the extension isn't + * loaded. + */ +export type ConcurrencyCooldownResult = + | { readonly ok: true; readonly providerId: string; readonly cooldownMs: number } + | { readonly ok: false; readonly error: string }; + +/** + * Outcome of an auto-reduce banner's "Restore to N" action (PUT the limit back + * to `autoReducedFrom` via `PUT /concurrency/limits/:providerId`). Carried back to + * the banner so a FAILED restore surfaces an inline error next to the button + * (instead of silently re-enabling the button / showing the error far away). + */ +export type RestoreOutcome = { readonly ok: true } | { readonly ok: false; readonly error: string }; + // ── Injected ports (consumer-defines-port; the composition root adapts the // store's HTTP calls to these shapes). ────────────────────────────────────── @@ -80,3 +110,10 @@ export type SaveConcurrencyLimit = ( ) => Promise<ConcurrencyLimitResult>; export type DeleteConcurrencyLimit = (providerId: string) => Promise<ConcurrencyDeleteResult>; export type LoadConcurrencyStatus = () => Promise<ConcurrencyStatusResult>; +/** `GET /concurrency/cooldown/:providerId` — read the per-slot release cooldown. */ +export type GetConcurrencyCooldown = (providerId: string) => Promise<ConcurrencyCooldownResult>; +/** `PUT /concurrency/cooldown/:providerId` — set the per-slot release cooldown (non-negative int). */ +export type SaveConcurrencyCooldown = ( + providerId: string, + cooldownMs: number, +) => Promise<ConcurrencyCooldownResult>; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 82b72b0..c284ad0 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -1,16 +1,22 @@ import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { + autoReduceNotices, + cooldownLabel, + DEFAULT_COOLDOWN_MS, formatPauseDuration, + normalizeConcurrencyCooldown, normalizeConcurrencyLimit, normalizeConcurrencyLimits, normalizeConcurrencyStatus, + parseCooldownInput, parseLimitInput, pauseLabel, providerFromModel, providerOptions, summarizeLimits, summarizeStatus, + viewAutoReduce, viewConcurrencyLimit, viewConcurrencyLimits, viewConcurrencyStatus, @@ -23,6 +29,8 @@ const status = (over: Partial<ConcurrencyStatusEntry> = {}): ConcurrencyStatusEn inFlight: 2, queued: 0, paused: false, + cooldownMs: 350, + autoReduced: false, ...over, }); @@ -46,6 +54,41 @@ describe("parseLimitInput", () => { }); }); +// ── parseCooldownInput (non-negative integer — 0 is valid, unlike the limit) ── + +describe("parseCooldownInput", () => { + it("accepts zero + positive integers", () => { + expect(parseCooldownInput("0")).toBe(0); + expect(parseCooldownInput("350")).toBe(350); + expect(parseCooldownInput(" 100 ")).toBe(100); + }); + + it("rejects negatives, non-integers, and garbage", () => { + expect(parseCooldownInput("-1")).toBeNull(); + expect(parseCooldownInput("4.5")).toBeNull(); + expect(parseCooldownInput("")).toBeNull(); + expect(parseCooldownInput("abc")).toBeNull(); + expect(parseCooldownInput("100ms")).toBeNull(); + }); +}); + +// ── cooldownLabel ───────────────────────────────────────────────────────────── + +describe("cooldownLabel", () => { + it("0 → off label", () => { + expect(cooldownLabel(0)).toBe("0ms (off)"); + }); + it("sub-second → ms", () => { + expect(cooldownLabel(350)).toBe("350ms"); + expect(cooldownLabel(999)).toBe("999ms"); + }); + it("≥1s → seconds (trims trailing .0)", () => { + expect(cooldownLabel(1000)).toBe("1s"); + expect(cooldownLabel(1500)).toBe("1.5s"); + expect(cooldownLabel(60_000)).toBe("60s"); + }); +}); + // ── providerFromModel / providerOptions ─────────────────────────────────────── describe("providerFromModel", () => { @@ -178,6 +221,8 @@ describe("viewConcurrencyStatus", () => { inFlight: Number.NaN, queued: "oops" as unknown as number, paused: false, + cooldownMs: Number.NaN, + autoReduced: false, }, 0, ); @@ -185,6 +230,7 @@ describe("viewConcurrencyStatus", () => { expect(v.inFlight).toBe(0); expect(v.queued).toBe(0); expect(v.inFlightLabel).toBe("0/1"); + expect(v.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); }); it("viewConcurrencyStatuses maps a list preserving order", () => { @@ -194,6 +240,81 @@ describe("viewConcurrencyStatus", () => { ); expect(views.map((v) => v.providerId)).toEqual(["a", "b"]); }); + + it("carries cooldownMs + label + autoReduced fields onto the view", () => { + const v = viewConcurrencyStatus(status({ cooldownMs: 1500 }), 0); + expect(v.cooldownMs).toBe(1500); + expect(v.cooldownLabel).toBe("1.5s"); + expect(v.autoReduced).toBe(false); + expect(v.autoReducedFrom).toBeNull(); + }); + + it("auto-reduced → warning badge (not busy) + autoReducedFrom carried", () => { + const v = viewConcurrencyStatus( + status({ limit: 3, autoReduced: true, autoReducedFrom: 4, inFlight: 0 }), + 0, + ); + expect(v.autoReduced).toBe(true); + expect(v.autoReducedFrom).toBe(4); + expect(v.badge).toBe("warning"); + // autoReduced alone does NOT flip busy (a reduced limit still admits agents). + expect(v.busy).toBe(false); + }); +}); + +// ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ─────────── + +describe("viewAutoReduce", () => { + it("returns null when not auto-reduced", () => { + expect(viewAutoReduce(status({ autoReduced: false }))).toBeNull(); + }); + + it("uses the backend notice verbatim + carries from/current limits", () => { + const notice = viewAutoReduce( + status({ + limit: 3, + autoReduced: true, + autoReducedFrom: 4, + notice: "Concurrency limit auto-reduced to 3 after a 429.", + }), + ); + expect(notice).toEqual({ + providerId: "umans", + message: "Concurrency limit auto-reduced to 3 after a 429.", + fromLimit: 4, + currentLimit: 3, + }); + }); + + it("synthesizes a fallback notice when the backend notice is absent/empty", () => { + expect(viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4 }))).toEqual({ + providerId: "umans", + message: "Concurrency limit auto-reduced to 3 after a 429 — restore manually when ready.", + fromLimit: 4, + currentLimit: 3, + }); + expect( + viewAutoReduce(status({ limit: 3, autoReduced: true, autoReducedFrom: 4, notice: "" })), + ).not.toBeNull(); + }); + + it("falls back to currentLimit+1 when autoReducedFrom is missing/garbage", () => { + const notice = viewAutoReduce(status({ limit: 3, autoReduced: true })); + expect(notice?.fromLimit).toBe(4); // 3 + 1 + }); +}); + +describe("autoReduceNotices", () => { + it("collects one banner per auto-reduced provider (input order), empty when none", () => { + expect(autoReduceNotices([status({ providerId: "a" })])).toEqual([]); + const out = autoReduceNotices([ + status({ providerId: "a", autoReduced: true, autoReducedFrom: 4, limit: 3 }), + status({ providerId: "b" }), + status({ providerId: "c", autoReduced: true, autoReducedFrom: 2, limit: 1 }), + ]); + expect(out.map((n) => n.providerId)).toEqual(["a", "c"]); + expect(out[1]?.fromLimit).toBe(2); + }); }); // ── viewConcurrencyLimit ─────────────────────────────────────────────────────── @@ -273,6 +394,16 @@ describe("summarizeStatus", () => { "1 provider · 1/4 in flight", ); }); + it("includes an auto-reduced fragment only when non-zero", () => { + const s = summarizeStatus( + [ + status({ providerId: "a", limit: 3, inFlight: 1, autoReduced: true, autoReducedFrom: 4 }), + status({ providerId: "b", limit: 4, inFlight: 1 }), + ], + 0, + ); + expect(s).toBe("2 providers · 2/7 in flight · 1 auto-reduced"); + }); }); // ── Network-seam normalizers ─────────────────────────────────────────────────── @@ -359,6 +490,8 @@ describe("normalizeConcurrencyStatus", () => { inFlight: 2, queued: 1, paused: false, + cooldownMs: 350, + autoReduced: false, }); expect(first !== undefined && !("pausedUntil" in first)).toBe(true); expect(second).toEqual({ @@ -368,6 +501,8 @@ describe("normalizeConcurrencyStatus", () => { queued: 3, paused: true, pausedUntil: now, + cooldownMs: 350, + autoReduced: false, }); }); @@ -388,8 +523,24 @@ describe("normalizeConcurrencyStatus", () => { ], }); expect(providers).toEqual([ - { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, - { providerId: "x", limit: 1, inFlight: 0, queued: 0, paused: false }, + { + providerId: "umans", + limit: 4, + inFlight: 2, + queued: 1, + paused: false, + cooldownMs: 350, + autoReduced: false, + }, + { + providerId: "x", + limit: 1, + inFlight: 0, + queued: 0, + paused: false, + cooldownMs: 350, + autoReduced: false, + }, ]); }); @@ -402,4 +553,81 @@ describe("normalizeConcurrencyStatus", () => { }); for (const p of providers) expect("pausedUntil" in p).toBe(false); }); + + it("coerces cooldownMs (default 350) + carries auto-reduce fields only when true", () => { + const [reduced, healthy] = normalizeConcurrencyStatus({ + providers: [ + { + providerId: "umans", + limit: 3, + inFlight: 1, + queued: 0, + paused: false, + cooldownMs: 500, + autoReduced: true, + autoReducedFrom: 4, + notice: "auto-reduced to 3 after a 429.", + }, + { providerId: "openai", limit: 4, inFlight: 0, queued: 0, paused: false }, + ], + }); + expect(reduced?.cooldownMs).toBe(500); + expect(reduced?.autoReduced).toBe(true); + expect(reduced?.autoReducedFrom).toBe(4); + expect(reduced?.notice).toBe("auto-reduced to 3 after a 429."); + // Healthy entry: cooldownMs defaults to 350 when absent; auto-reduce fields + // are NOT present (they are only included when autoReduced===true). + expect(healthy?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); + expect(healthy?.autoReduced).toBe(false); + expect(healthy && "autoReducedFrom" in healthy).toBe(false); + expect(healthy && "notice" in healthy).toBe(false); + }); + + it("drops autoReducedFrom/notice when autoReduced is false (even if present in JSON)", () => { + const [p] = normalizeConcurrencyStatus({ + providers: [ + { + providerId: "x", + limit: 4, + inFlight: 0, + queued: 0, + paused: false, + autoReduced: false, + autoReducedFrom: 9, + notice: "stale", + }, + ], + }); + expect(p?.autoReduced).toBe(false); + expect(p && "autoReducedFrom" in p).toBe(false); + expect(p && "notice" in p).toBe(false); + }); +}); + +// ── normalizeConcurrencyCooldown ─────────────────────────────────────────────── + +describe("normalizeConcurrencyCooldown", () => { + it("coerces a well-formed body", () => { + expect(normalizeConcurrencyCooldown({ providerId: "umans", cooldownMs: 500 })).toEqual({ + providerId: "umans", + cooldownMs: 500, + }); + }); + + it("defaults a malformed/absent cooldownMs to 350", () => { + expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: -1 })?.cooldownMs).toBe( + DEFAULT_COOLDOWN_MS, + ); + expect(normalizeConcurrencyCooldown({ providerId: "x" })?.cooldownMs).toBe(DEFAULT_COOLDOWN_MS); + expect(normalizeConcurrencyCooldown({ providerId: "x", cooldownMs: "fast" })?.cooldownMs).toBe( + DEFAULT_COOLDOWN_MS, + ); + }); + + it("returns null for a missing/malformed providerId", () => { + expect(normalizeConcurrencyCooldown({ cooldownMs: 350 })).toBeNull(); + expect(normalizeConcurrencyCooldown({ providerId: "", cooldownMs: 350 })).toBeNull(); + expect(normalizeConcurrencyCooldown(null)).toBeNull(); + expect(normalizeConcurrencyCooldown({})).toBeNull(); + }); }); diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 7a4fe0e..e05423b 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -1,12 +1,16 @@ -import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import type { + ConcurrencyCooldownResponse, + 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. + * display shapes (badges, "2/4" in-flight labels, pause countdowns, cooldown + * labels, auto-reduce banners, summaries), holds the limit/cooldown-input + * parsing, and the network-seam normalizers the composition root coerces the + * untyped JSON with. */ export type Badge = "success" | "warning" | "error" | "neutral"; @@ -36,6 +40,29 @@ export interface ConcurrencyStatusView { readonly badge: Badge; /** True when paused or at capacity (show a spinner). */ readonly busy: boolean; + /** Per-slot release cooldown in ms (defensive default 350 on garbage). */ + readonly cooldownMs: number; + /** "350ms" / "1.2s" / "0ms (off)" — display label for the cooldown. */ + readonly cooldownLabel: string; + /** Whether the limit was auto-reduced by a 429 (one-way; user restores manually). */ + readonly autoReduced: boolean; + /** The original limit before auto-reduction; null when not auto-reduced. */ + readonly autoReducedFrom: number | null; +} + +/** + * A view-model for the auto-reduce banner — derived from a status entry whose + * `autoReduced` is `true`. `message` is the backend's `notice` when present, else + * a synthesized fallback. `viewAutoReduce` returns this (or null) so the banner + * section renders without reaching into the raw entry. + */ +export interface AutoReduceNotice { + readonly providerId: string; + readonly message: string; + /** The original limit before reduction — the value "Restore to N" PUTs. */ + readonly fromLimit: number; + /** The current (reduced) limit. */ + readonly currentLimit: number; } // ── Limit input parsing ─────────────────────────────────────────────────────── @@ -63,6 +90,53 @@ export function normalizeLimit(value: unknown): number { return int >= 1 ? int : 1; } +// ── Cooldown input parsing ──────────────────────────────────────────────────── +// +// The per-slot release cooldown (ms) is a NON-NEGATIVE integer (0 = no cooldown, +// instant re-admission) — unlike the limit, 0 is a VALID value. The default is +// 350ms (the backend's server default when a limit is set but no explicit +// cooldown was configured). It is configurable + persisted per provider via +// `PUT /concurrency/cooldown/:providerId`. + +/** The server's default cooldown (ms) — used when none is explicitly set. */ +export const DEFAULT_COOLDOWN_MS = 350; + +/** + * Parse a raw cooldown input into a non-negative integer, or `null` when it is + * not valid. Accepts "0" → 0, "350" → 350; rejects "-1", "4.5", "", "abc". + * Drives the cooldown Save button's disabled state so an invalid value never + * reaches the backend (the backend 400s a non-negative-integer body). + */ +export function parseCooldownInput(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 >= 0 ? n : null; +} + +/** + * Coerce an untrusted cooldown value into a non-negative integer (default + * {@link DEFAULT_COOLDOWN_MS}). Used when normalizing backend responses so a + * malformed `cooldownMs` can never be negative/non-finite. + */ +export function normalizeCooldown(value: unknown): number { + const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_COOLDOWN_MS; + const int = Math.floor(n); + return int >= 0 ? int : DEFAULT_COOLDOWN_MS; +} + +/** + * Format a cooldown (ms) as a short display label: + * 0 → "0ms (off)" · <1000 → "350ms" · ≥1000 → "1.2s" (trailing ".0" trimmed). + */ +export function cooldownLabel(ms: number): string { + if (ms <= 0) return "0ms (off)"; + if (ms < 1000) return `${ms}ms`; + const secs = ms / 1000; + const fixed = secs.toFixed(1); + return `${fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed}s`; +} + // ── Provider options (the Add-form dropdown) ─────────────────────────────────── // // A concurrency `providerId` is the credential name that prefixes a model name @@ -140,6 +214,10 @@ export function pauseLabel( * 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). + * + * `autoReduced` does NOT flip `busy` (a reduced limit still admits agents; it is + * a degraded-but-active state surfaced via the banner, not a spinner) — it only + * nudges the badge to `warning` so the row signals attention. */ export function viewConcurrencyStatus( entry: ConcurrencyStatusEntry, @@ -149,12 +227,21 @@ export function viewConcurrencyStatus( const inFlight = clampCount(entry.inFlight); const queued = clampCount(entry.queued); const paused = entry.paused === true; + const autoReduced = entry.autoReduced === true; const atCapacity = inFlight >= limit; let badge: Badge; if (paused) badge = "warning"; + else if (autoReduced) badge = "warning"; else if (atCapacity && queued > 0) badge = "warning"; else if (inFlight > 0) badge = "success"; else badge = "neutral"; + const cooldownMs = normalizeCooldown(entry.cooldownMs); + const autoReducedFrom = + autoReduced && + typeof entry.autoReducedFrom === "number" && + Number.isFinite(entry.autoReducedFrom) + ? normalizeLimit(entry.autoReducedFrom) + : null; return { providerId: entry.providerId, limit, @@ -166,9 +253,53 @@ export function viewConcurrencyStatus( pausedLabel: pauseLabel(paused, entry.pausedUntil, now), badge, busy: paused || (atCapacity && queued > 0), + cooldownMs, + cooldownLabel: cooldownLabel(cooldownMs), + autoReduced, + autoReducedFrom, }; } +/** + * The auto-reduce banner view for a status entry, or `null` when it is not + * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present + * + non-empty); otherwise a synthesized fallback is built from + * `autoReducedFrom` → `limit`. `fromLimit` is the value "Restore to N" PUTs back. + */ +export function viewAutoReduce(entry: ConcurrencyStatusEntry): AutoReduceNotice | null { + if (entry.autoReduced !== true) return null; + const currentLimit = normalizeLimit(entry.limit); + const fromLimit = + typeof entry.autoReducedFrom === "number" && Number.isFinite(entry.autoReducedFrom) + ? normalizeLimit(entry.autoReducedFrom) + : currentLimit + 1; + const notice = + typeof entry.notice === "string" && entry.notice.length > 0 + ? entry.notice + : `Concurrency limit auto-reduced to ${currentLimit} after a 429 — restore manually when ready.`; + return { + providerId: entry.providerId, + message: notice, + fromLimit, + currentLimit, + }; +} + +/** + * All auto-reduce banners across a status list (one per auto-reduced provider), + * in input order. Empty when none are auto-reduced. + */ +export function autoReduceNotices( + entries: readonly ConcurrencyStatusEntry[], +): readonly AutoReduceNotice[] { + const out: AutoReduceNotice[] = []; + for (const e of entries) { + const n = viewAutoReduce(e); + if (n !== null) out.push(n); + } + return out; +} + export function viewConcurrencyStatuses( entries: readonly ConcurrencyStatusEntry[], now: number = Date.now(), @@ -197,8 +328,8 @@ export function summarizeLimits(limits: readonly ConcurrencyLimitEntry[]): strin /** * 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. + * "2 providers · 6/10 in flight · 1 queued · 1 paused · 1 auto-reduced". Only the + * queued / paused / auto-reduced fragments appear when non-zero. */ export function summarizeStatus( providers: readonly ConcurrencyStatusEntry[], @@ -209,12 +340,14 @@ export function summarizeStatus( let limitTotal = 0; let queued = 0; let paused = 0; + let autoReduced = 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; + if (p.autoReduced === true) autoReduced += 1; } const parts: string[] = []; parts.push( @@ -223,6 +356,7 @@ export function summarizeStatus( ); if (queued > 0) parts.push(`${queued} queued`); if (paused > 0) parts.push(`${paused} paused`); + if (autoReduced > 0) parts.push(`${autoReduced} auto-reduced`); // Touch `now` so the summary recomputes alongside the per-row pause countdown. void now; return parts.join(" · "); @@ -274,7 +408,9 @@ export function normalizeConcurrencyLimit(data: unknown): ConcurrencyLimitEntry /** * 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). + * not paused). `cooldownMs` (default 350) + `autoReduced` are always coerced; + * `autoReducedFrom` + `notice` are included only when `autoReduced` is true (and + * well-formed), mirroring the backend's "present only when auto-reduced" contract. */ export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyStatusEntry[] { if (!isRecord(data) || !Array.isArray(data.providers)) return []; @@ -282,18 +418,57 @@ export function normalizeConcurrencyStatus(data: unknown): readonly ConcurrencyS 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 providerId = asString(r.providerId) ?? ""; + const limit = normalizeLimit(r.limit); + const inFlight = clampCount(r.inFlight); + const queued = clampCount(r.queued); + const paused = r.paused === true; + const cooldownMs = normalizeCooldown(r.cooldownMs); + const autoReduced = r.autoReduced === true; + // Build immutably (the contract fields are readonly): start with the always- + // present fields, then layer the optional `pausedUntil` (finite number only) + // + the auto-reduce-only `autoReducedFrom`/`notice` (present only when true). + let entry: ConcurrencyStatusEntry = { + providerId, + limit, + inFlight, + queued, + paused, + cooldownMs, + autoReduced, }; - const pausedUntil = - typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil) - ? r.pausedUntil - : undefined; - return pausedUntil !== undefined ? { ...base, pausedUntil } : base; + if (typeof r.pausedUntil === "number" && Number.isFinite(r.pausedUntil)) { + entry = { ...entry, pausedUntil: r.pausedUntil }; + } + if (autoReduced) { + // Accumulate the auto-reduce-only optionals into a plain record (the + // contract fields are readonly, so we can't mutate a typed partial — + // collect then spread into a fresh entry). + const patch: { autoReducedFrom?: number; notice?: string } = {}; + if (typeof r.autoReducedFrom === "number" && Number.isFinite(r.autoReducedFrom)) { + patch.autoReducedFrom = normalizeLimit(r.autoReducedFrom); + } + if (typeof r.notice === "string" && r.notice.length > 0) { + patch.notice = r.notice; + } + if (patch.autoReducedFrom !== undefined || patch.notice !== undefined) { + entry = { ...entry, ...patch }; + } + } + return entry; }) .filter((r) => r.providerId !== ""); } + +/** + * Coerce an untrusted `GET`/`PUT /concurrency/cooldown/:providerId` body into a + * typed cooldown response, or `null` when it is malformed (missing/malformed + * `providerId` or `cooldownMs`). The composition root surfaces a 404/400/503 as + * `ok: false` separately; this only defends the success body. + */ +export function normalizeConcurrencyCooldown(data: unknown): ConcurrencyCooldownResponse | null { + if (!isRecord(data)) return null; + const providerId = asString(data.providerId); + if (providerId === null) return null; + return { providerId, cooldownMs: normalizeCooldown(data.cooldownMs) }; +} |
