From 8fd7ccc594c57fc6e0adfeeeff7045b504f7ea07 Mon Sep 17 00:00:00 2001 From: "Orca (ecs-claude)" Date: Fri, 14 Aug 2026 14:30:05 +0800 Subject: [PATCH] feat(console): poll-immune in-flight guard for start/stop (debounce fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #39 disable lived on the button DOM node, so the 5s roster poll could re-render and hand back a fresh enabled button mid-action (the underlying deploy_scale is idempotent, so it was harmless but not a real guard). Move the guard to module state: `scaling` maps deploymentKey → the desiredCount we're driving toward. The action button renders disabled whenever its key is in the set, so a poll re-render preserves the disabled state. The key clears when the roster observes the target count (poll-immune), or by a 15s safety timeout so a never-observed flip can't wedge a button. Re-entry is refused while a key is in flight. - render.ts: `deploymentKey(d)`; rosterHtml/renderRoster take an optional `pending` set; a pending row renders a disabled `…` placeholder (no data-action, so even a stray click no-ops). - main.ts: `scaling`/`scaleTimers` maps, prunePending on each tick, repaint for instant feedback; scale() sets the guard, awaits, prunes via tick() or errors out clearing it. - tests: pending → disabled placeholder; others stay live; deploymentKey. Verified: tsc + 38 vitest + vite build green. Co-Authored-By: Claude Opus 4.8 --- console/src/main.ts | 70 +++++++++++++++++++++++++++++++++----- console/src/render.test.ts | 33 ++++++++++++++++++ console/src/render.ts | 39 ++++++++++++++++----- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/console/src/main.ts b/console/src/main.ts index 4f379a8..3521cc7 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -5,8 +5,9 @@ import { renderFleetConfig, renderRemote, filterByMembers, + deploymentKey, } from "./render"; -import type { FleetConfig, RemoteConfig } from "./types"; +import type { Deployment, FleetConfig, RemoteConfig } from "./types"; import { createPane, bindBackend, type Level } from "./log"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; @@ -96,13 +97,54 @@ function errText(e: unknown): string { let lastError = ""; +// ---- in-flight scale guard -------------------------------------------------- +// Deployments with a scale in flight (or awaiting the observed desiredCount +// flip), keyed by `deploymentKey` → the count we're driving toward. Held in +// module state (not on the button DOM) so the 5s poll's re-render can't wash out +// the disabled guard. Pruned when the roster observes the target count, or by a +// safety timeout so a never-observed flip can't wedge a button forever. +const scaling = new Map(); +const scaleTimers = new Map(); +let lastDeployments: Deployment[] = []; +const SCALE_MAX_HOLD_MS = 15000; + +function pendingKeys(): ReadonlySet { + return new Set(scaling.keys()); +} + +function clearPending(key: string): void { + scaling.delete(key); + const timer = scaleTimers.get(key); + if (timer !== undefined) { + window.clearTimeout(timer); + scaleTimers.delete(key); + } +} + +// Drop the guard for any deployment whose observed desiredCount has reached the +// target we drove toward — the action landed, so its button re-enables. +function prunePending(deployments: Deployment[]): void { + for (const d of deployments) { + const key = deploymentKey(d); + if (scaling.get(key) === d.desired) clearPending(key); + } +} + +// Re-render the roster from the last poll's data with the current pending +// overlay — instant feedback on click, no fetch needed. +function repaintRoster(): void { + if (roster) renderRoster(roster, lastDeployments, pendingKeys()); +} + async function tick(): Promise { if (!roster) return; try { const all = await source.listDeployments(activeCluster); // Filter to the active fleet's members (empty ⇒ whole cluster). const deployments = filterByMembers(all, activeMembers); - renderRoster(roster, deployments); + lastDeployments = deployments; + prunePending(deployments); + renderRoster(roster, deployments, pendingKeys()); if (lastError) { note("info", `roster recovered — ${deployments.length} deployment(s)`); lastError = ""; @@ -305,23 +347,35 @@ if (remoteEl) { // ---- start / stop (ADR-2 write model: stop = scale→0, start = scale→1) ------- // Scale a deployment off (0) or on (1). Reversible — ECS keeps the Spec at -// desiredCount 0 — so this needs no state store. On success `tick()` re-renders -// the roster (which recycles the button DOM), so we only re-enable on error. +// desiredCount 0 — so this needs no state store. The in-flight guard lives in +// `scaling` (module state), so the button stays disabled across poll re-renders +// until the observed desiredCount flips (or the safety timeout fires). async function scale( action: "start" | "stop", name: string, namespace: string, - btn: HTMLButtonElement, ): Promise { + const key = `${namespace}/${name}`; + if (scaling.has(key)) return; // already in flight — poll-immune re-entry guard const size = action === "start" ? 1 : 0; - btn.disabled = true; + scaling.set(key, size); + scaleTimers.set( + key, + window.setTimeout(() => { + clearPending(key); + repaintRoster(); + }, SCALE_MAX_HOLD_MS), + ); + repaintRoster(); // disable the button immediately try { await source.scaleDeployment(name, size, namespace, activeCluster); note("info", `${action === "start" ? "started" : "stopped"} ${namespace}/${name}`); + // tick() observes the new desiredCount and prunes the guard when it flips. await tick(); } catch (e) { note("error", `${action} ${namespace}/${name}: ${errText(e)}`); - btn.disabled = false; + clearPending(key); + repaintRoster(); } } @@ -350,7 +404,7 @@ if (roster) { }, 3000); return; } - void scale(action, name, namespace, btn); + void scale(action, name, namespace); }); } diff --git a/console/src/render.test.ts b/console/src/render.test.ts index ae8b78e..e2fa253 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -6,6 +6,7 @@ import { remoteHtml, filterByMembers, serviceName, + deploymentKey, } from "./render"; import { FIXTURE_DEPLOYMENTS, @@ -101,6 +102,38 @@ describe("rosterHtml", () => { expect(html).toContain(""x"); expect(html).not.toContain('data-name=""x"'); }); + + it("renders a disabled placeholder for a deployment with a scale in flight", () => { + const d = dep({ name: "orca", namespace: "prod" }); + const html = rosterHtml([d], new Set([deploymentKey(d)])); + expect(html).toContain("act-pending"); + expect(html).toContain("disabled"); + // no live action attributes on a pending button + expect(html).not.toContain('data-action="stop"'); + expect(html).not.toContain('data-action="start"'); + }); + + it("leaves non-pending deployments interactive when another is in flight", () => { + const busy = dep({ name: "orca", namespace: "prod" }); + const free = dep({ name: "mira", namespace: "prod", desired: 0 }); + const html = rosterHtml([busy, free], new Set([deploymentKey(busy)])); + // orca is pending → placeholder; mira is free → a live Start button + expect(html).toContain("act-pending"); + expect(html).toContain('data-action="start"'); + expect(html).toContain('data-name="mira"'); + }); + + it("defaults to no pending set (all buttons live)", () => { + const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]); + expect(html).not.toContain("act-pending"); + expect(html).toContain('data-action="stop"'); + }); +}); + +describe("deploymentKey", () => { + it("is the namespace/name pair (not the ECS service name)", () => { + expect(deploymentKey({ ...FIXTURE_DEPLOYMENTS[0] })).toBe("prod/orca"); + }); }); describe("identityHtml", () => { diff --git a/console/src/render.ts b/console/src/render.ts index 46bc4c4..2515e1c 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -27,12 +27,27 @@ function badge(state: AgentState): string { return `${state}`; } +// A deployment's identity within the roster — the key the in-flight scale guard +// (`main.ts`) and the render agree on. Not the ECS service name; just the +// namespace/name pair a scale action targets. +export function deploymentKey(d: Deployment): string { + return `${d.namespace}/${d.name}`; +} + // Start (scale→1) when the deployment is off, Stop (scale→0) when it's on. // Stop keeps the Spec — ECS retains the service at desiredCount 0 — so it's // reversible, no state store needed. The `data-*` carry the identity the // delegated handler needs; the managing credential is resolved per-cluster, so // the row only needs name + namespace (service = `oab-{namespace}-{name}`). -function actionButton(d: Deployment): string { +// +// `pending` ⇒ a scale is in flight (or the observed count hasn't flipped yet): +// render a disabled placeholder so the 5s poll re-render can't hand back a fresh +// enabled button mid-action. The guard lives in module state, not on the DOM +// node, so it survives the re-render. +function actionButton(d: Deployment, pending: boolean): string { + if (pending) { + return ``; + } const off = d.desired === 0; const action = off ? "start" : "stop"; const label = off ? "Start" : "Stop"; @@ -40,7 +55,7 @@ function actionButton(d: Deployment): string { return ``; } -function rowHtml(d: Deployment): string { +function rowHtml(d: Deployment, pending: ReadonlySet): string { const phases = d.instances.length ? d.instances.map((i) => badge(i.state)).join(" ") : ``; @@ -50,7 +65,7 @@ function rowHtml(d: Deployment): string { ${name} ${d.ready}/${d.desired} · cur ${d.current} ${phases} - ${actionButton(d)} + ${actionButton(d, pending.has(deploymentKey(d)))} `; } @@ -78,8 +93,12 @@ export function filterByMembers( } // Pure: deployments -> roster table HTML. Kept side-effect-free so it is unit -// testable without a DOM. -export function rosterHtml(deployments: Deployment[]): string { +// testable without a DOM. `pending` is the set of `deploymentKey`s with a scale +// in flight — their action buttons render disabled. +export function rosterHtml( + deployments: Deployment[], + pending: ReadonlySet = new Set(), +): string { if (deployments.length === 0) { return `

No deployments in this cluster.

`; } @@ -87,7 +106,7 @@ export function rosterHtml(deployments: Deployment[]): string { .sort((a, b) => `${a.namespace}/${a.name}`.localeCompare(`${b.namespace}/${b.name}`), ) - .map(rowHtml) + .map((d) => rowHtml(d, pending)) .join(""); return ` @@ -97,8 +116,12 @@ export function rosterHtml(deployments: Deployment[]): string {
`; } -export function renderRoster(el: HTMLElement, deployments: Deployment[]): void { - el.innerHTML = rosterHtml(deployments); +export function renderRoster( + el: HTMLElement, + deployments: Deployment[], + pending: ReadonlySet = new Set(), +): void { + el.innerHTML = rosterHtml(deployments, pending); } // ---- Runtime identity / context panel (ADR #19) ------------------------------