fix: refresh executor indicators and restore status details (#3821)
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||||
|
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
|
||||||
|
|
||||||
|
const pointer = vi.hoisted(() => ({ touch: false }));
|
||||||
|
vi.mock("@/hooks/use-compact-task-chrome", () => ({ useTouchDrawer: () => pointer.touch }));
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
pointer.touch = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// @covers AC-EXECUTORS-TASK-STATUS-001.3
|
||||||
|
it("links the actual tooltip to the focused DOM trigger and dismisses on Escape", async () => {
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<RemoteCloudTooltip
|
||||||
|
taskId="task"
|
||||||
|
executorType="k8s"
|
||||||
|
status={{ remote_name: "test-pod", remote_state: "running" }}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByTestId("remote-executor-status-trigger");
|
||||||
|
act(() => trigger.focus());
|
||||||
|
const tooltip = await screen.findByRole("tooltip");
|
||||||
|
expect(trigger.getAttribute("aria-describedby")).toBe(tooltip.id);
|
||||||
|
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||||
|
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
// @covers AC-EXECUTORS-TASK-STATUS-001.4
|
||||||
|
// Reviewer-requested coverage of the existing click and keyboard activation contract.
|
||||||
|
it.each(["click", "Enter", " "])(
|
||||||
|
"opens and closes the real drawer via %s without selecting its task",
|
||||||
|
async (activation) => {
|
||||||
|
pointer.touch = true;
|
||||||
|
const selectTask = vi.fn();
|
||||||
|
render(
|
||||||
|
<div onClick={selectTask}>
|
||||||
|
<RemoteCloudTooltip
|
||||||
|
taskId="task"
|
||||||
|
executorType="k8s"
|
||||||
|
status={{ remote_name: "touch-pod", remote_state: "running" }}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByTestId("remote-executor-status-trigger");
|
||||||
|
act(() => trigger.focus());
|
||||||
|
if (activation === "click") fireEvent.click(trigger);
|
||||||
|
else fireEvent.keyDown(trigger, { key: activation });
|
||||||
|
const drawer = await screen.findByRole("dialog");
|
||||||
|
expect(trigger.getAttribute("aria-controls")).toBe(drawer.id);
|
||||||
|
expect(selectTask).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
await waitFor(() => expect(trigger.getAttribute("aria-expanded")).toBe("false"));
|
||||||
|
expect(selectTask).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -16,7 +16,7 @@ vi.mock("@/lib/api/domains/kubernetes-api", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/ws/connection", () => ({
|
vi.mock("@/lib/ws/connection", () => ({
|
||||||
getWebSocketClient: () => ({ request: mocks.wsRequest }),
|
getWebSocketClient: () => ({ request: mocks.wsRequest, getStatus: () => "connected" }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
useState,
|
useState,
|
||||||
|
type ComponentPropsWithRef,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -35,7 +36,7 @@ type RemoteCloudTooltipProps = {
|
|||||||
executorType?: string | null;
|
executorType?: string | null;
|
||||||
fallbackName?: string | null;
|
fallbackName?: string | null;
|
||||||
iconClassName?: string;
|
iconClassName?: string;
|
||||||
/** When provided, uses this data directly instead of fetching via WS on hover. */
|
/** When provided, uses this data directly instead of subscribing to live status. */
|
||||||
status?: RemoteExecutorStatusData | null;
|
status?: RemoteExecutorStatusData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ const CLOUD_STATE_CLASSES: Record<ReturnType<typeof getCloudState>, string> = {
|
|||||||
stale: "text-muted-foreground",
|
stale: "text-muted-foreground",
|
||||||
};
|
};
|
||||||
|
|
||||||
type StatusTriggerProps = {
|
type StatusTriggerProps = Omit<ComponentPropsWithRef<"span">, "onFocus" | "onBlur"> & {
|
||||||
touch: boolean;
|
touch: boolean;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -83,10 +84,12 @@ function StatusTrigger({
|
|||||||
onBlur,
|
onBlur,
|
||||||
onTouchClick,
|
onTouchClick,
|
||||||
onTouchKeyDown,
|
onTouchKeyDown,
|
||||||
|
...triggerProps
|
||||||
}: StatusTriggerProps) {
|
}: StatusTriggerProps) {
|
||||||
const Icon = icon.Icon;
|
const Icon = icon.Icon;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
{...triggerProps}
|
||||||
data-testid="remote-executor-status-trigger"
|
data-testid="remote-executor-status-trigger"
|
||||||
role={touch ? "button" : "img"}
|
role={touch ? "button" : "img"}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
@@ -97,11 +100,18 @@ function StatusTrigger({
|
|||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onClick={onTouchClick}
|
onClick={(event) => {
|
||||||
onKeyDown={onTouchKeyDown}
|
onTouchClick?.(event);
|
||||||
|
triggerProps.onClick?.(event);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
onTouchKeyDown?.(event);
|
||||||
|
triggerProps.onKeyDown?.(event);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex shrink-0 items-center justify-center",
|
"relative inline-flex shrink-0 items-center justify-center",
|
||||||
CLOUD_STATE_CLASSES[cloudState],
|
CLOUD_STATE_CLASSES[cloudState],
|
||||||
|
triggerProps.className,
|
||||||
touch &&
|
touch &&
|
||||||
"cursor-pointer transition-transform duration-150 ease-out active:scale-[0.96] after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-['']",
|
"cursor-pointer transition-transform duration-150 ease-out active:scale-[0.96] after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-['']",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,6 +28,19 @@ async function expectStatusFailure(icon: Locator, page: Page) {
|
|||||||
await trigger.hover();
|
await trigger.hover();
|
||||||
const tooltip = openTooltip(page);
|
const tooltip = openTooltip(page);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
|
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
|
||||||
|
const panel = page.locator('[data-slot="tooltip-content"]:not([data-state="closed"])');
|
||||||
|
const panelBox = await panel.boundingBox();
|
||||||
|
const triggerBox = await trigger.boundingBox();
|
||||||
|
expect(panelBox).not.toBeNull();
|
||||||
|
expect(triggerBox).not.toBeNull();
|
||||||
|
expect(panelBox!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
|
||||||
|
expect(
|
||||||
|
Math.min(
|
||||||
|
Math.abs(panelBox!.y + panelBox!.height - triggerBox!.y),
|
||||||
|
Math.abs(triggerBox!.y + triggerBox!.height - panelBox!.y),
|
||||||
|
),
|
||||||
|
).toBeLessThan(30);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
|
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-error")).toContainText("Unauthorized");
|
await expect(tooltip.getByTestId("remote-executor-status-error")).toContainText("Unauthorized");
|
||||||
}
|
}
|
||||||
@@ -39,6 +52,19 @@ async function expectStatusHealthy(icon: Locator, page: Page) {
|
|||||||
await trigger.hover();
|
await trigger.hover();
|
||||||
const tooltip = openTooltip(page);
|
const tooltip = openTooltip(page);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
|
await expect(tooltip.getByTestId("remote-executor-status-summary")).toBeVisible();
|
||||||
|
const panel = page.locator('[data-slot="tooltip-content"]:not([data-state="closed"])');
|
||||||
|
const panelBox = await panel.boundingBox();
|
||||||
|
const triggerBox = await trigger.boundingBox();
|
||||||
|
expect(panelBox).not.toBeNull();
|
||||||
|
expect(triggerBox).not.toBeNull();
|
||||||
|
expect(panelBox!.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(panelBox!.x + panelBox!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
|
||||||
|
expect(
|
||||||
|
Math.min(
|
||||||
|
Math.abs(panelBox!.y + panelBox!.height - triggerBox!.y),
|
||||||
|
Math.abs(triggerBox!.y + triggerBox!.height - panelBox!.y),
|
||||||
|
),
|
||||||
|
).toBeLessThan(30);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
|
await expect(tooltip.getByTestId("remote-executor-status-identity")).toHaveText(INITIAL_POD_NAME);
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-state")).toContainText("running");
|
await expect(tooltip.getByTestId("remote-executor-status-state")).toContainText("running");
|
||||||
await expect(tooltip.getByTestId("remote-executor-status-restarts")).toContainText("3");
|
await expect(tooltip.getByTestId("remote-executor-status-restarts")).toContainText("3");
|
||||||
@@ -146,6 +172,7 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await testPage.clock.install();
|
||||||
const kanban = new KanbanPage(testPage);
|
const kanban = new KanbanPage(testPage);
|
||||||
let readsBeforeRender = sessionReads;
|
let readsBeforeRender = sessionReads;
|
||||||
await kanban.goto();
|
await kanban.goto();
|
||||||
@@ -154,7 +181,8 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
|
|||||||
await expectStatusFailure(kanbanIcon, testPage);
|
await expectStatusFailure(kanbanIcon, testPage);
|
||||||
statusUnauthorized = false;
|
statusUnauthorized = false;
|
||||||
readsBeforeRender = sessionReads;
|
readsBeforeRender = sessionReads;
|
||||||
await testPage.reload();
|
await testPage.mouse.move(0, 0);
|
||||||
|
await testPage.clock.fastForward(90_001);
|
||||||
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
|
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
|
||||||
await expectStatusHealthy(
|
await expectStatusHealthy(
|
||||||
kanban.taskCard(task.id).getByTestId("executor-status-kubernetes-icon"),
|
kanban.taskCard(task.id).getByTestId("executor-status-kubernetes-icon"),
|
||||||
@@ -175,8 +203,8 @@ test("Kubernetes task icons hydrate eagerly and publish the structured Pod summa
|
|||||||
);
|
);
|
||||||
statusUnauthorized = false;
|
statusUnauthorized = false;
|
||||||
readsBeforeRender = sessionReads;
|
readsBeforeRender = sessionReads;
|
||||||
await testPage.reload();
|
await testPage.mouse.move(0, 0);
|
||||||
await session.waitForLoad(30_000);
|
await testPage.clock.fastForward(90_001);
|
||||||
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
|
await expectEagerStatusRead(() => sessionReads, readsBeforeRender);
|
||||||
await expectStatusHealthy(
|
await expectStatusHealthy(
|
||||||
session.sidebar
|
session.sidebar
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
|
|||||||
let taskId = "";
|
let taskId = "";
|
||||||
let navigationTaskId = "";
|
let navigationTaskId = "";
|
||||||
let sessionReads = 0;
|
let sessionReads = 0;
|
||||||
|
let statusUnauthorized = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const task = await apiClient.createTaskWithAgent(
|
const task = await apiClient.createTaskWithAgent(
|
||||||
@@ -110,6 +111,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
|
|||||||
pod_phase: "Running",
|
pod_phase: "Running",
|
||||||
container_state: "running",
|
container_state: "running",
|
||||||
restarts: 0,
|
restarts: 0,
|
||||||
|
failure_reason: statusUnauthorized ? "Unauthorized" : undefined,
|
||||||
workspace_kind: "empty_dir",
|
workspace_kind: "empty_dir",
|
||||||
created_at: "2026-08-25T10:00:00Z",
|
created_at: "2026-08-25T10:00:00Z",
|
||||||
},
|
},
|
||||||
@@ -118,6 +120,7 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await testPage.clock.install();
|
||||||
await testPage.goto(`/t/${navigationTaskId}`);
|
await testPage.goto(`/t/${navigationTaskId}`);
|
||||||
await new SessionPage(testPage).waitForLoad(30_000);
|
await new SessionPage(testPage).waitForLoad(30_000);
|
||||||
|
|
||||||
@@ -126,6 +129,11 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
|
|||||||
const taskRow = taskSwitcher.locator(`[data-task-row-id="${task.id}"]`);
|
const taskRow = taskSwitcher.locator(`[data-task-row-id="${task.id}"]`);
|
||||||
const statusTrigger = taskRow.getByTestId("remote-executor-status-trigger");
|
const statusTrigger = taskRow.getByTestId("remote-executor-status-trigger");
|
||||||
await expect.poll(() => sessionReads).toBeGreaterThan(0);
|
await expect.poll(() => sessionReads).toBeGreaterThan(0);
|
||||||
|
await expect(statusTrigger).toHaveClass(/text-destructive/);
|
||||||
|
statusUnauthorized = false;
|
||||||
|
const beforeRefresh = sessionReads;
|
||||||
|
await testPage.clock.fastForward(90_001);
|
||||||
|
await expect.poll(() => sessionReads).toBeGreaterThan(beforeRefresh);
|
||||||
await expect(statusTrigger).toHaveClass(/text-emerald-500/);
|
await expect(statusTrigger).toHaveClass(/text-emerald-500/);
|
||||||
await expect(statusTrigger).toHaveAttribute("aria-haspopup", "dialog");
|
await expect(statusTrigger).toHaveAttribute("aria-haspopup", "dialog");
|
||||||
await expectExpandedTouchTarget(statusTrigger, "Mobile task executor status action");
|
await expectExpandedTouchTarget(statusTrigger, "Mobile task executor status action");
|
||||||
@@ -140,6 +148,8 @@ test("Kubernetes task disclosure exposes live Pod details and safe actions by to
|
|||||||
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
|
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
|
||||||
await statusDrawer.getByRole("button", { name: "Close" }).tap();
|
await statusDrawer.getByRole("button", { name: "Close" }).tap();
|
||||||
await expect(statusDrawer).toBeHidden();
|
await expect(statusDrawer).toBeHidden();
|
||||||
|
await expect(statusTrigger).toBeFocused();
|
||||||
|
await assertNoDocumentHorizontalOverflow(testPage);
|
||||||
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
|
await expect(testPage).toHaveURL(new RegExp(`/t/${navigationTaskId}$`));
|
||||||
await testPage.goto(`/t/${task.id}`);
|
await testPage.goto(`/t/${task.id}`);
|
||||||
await new SessionPage(testPage).waitForLoad(30_000);
|
await new SessionPage(testPage).waitForLoad(30_000);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
type RefreshEntry = {
|
||||||
|
timer?: ReturnType<typeof setTimeout>;
|
||||||
|
dueAt: number;
|
||||||
|
refresh: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Schedules only subscribed scopes while the document is visible. */
|
||||||
|
export function createRemoteExecutorStatusRefresh(interval: number) {
|
||||||
|
const active = new Map<string, RefreshEntry>();
|
||||||
|
|
||||||
|
function schedule(entry: RefreshEntry) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
if (typeof document === "undefined" || document.visibilityState === "hidden") return;
|
||||||
|
entry.timer = setTimeout(entry.refresh, Math.max(0, entry.dueAt - Date.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
active.forEach(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activate(scope: string, refresh: () => void, dueAt: number) {
|
||||||
|
if (active.has(scope)) return;
|
||||||
|
if (active.size === 0 && typeof document !== "undefined") {
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
}
|
||||||
|
const entry = { refresh, dueAt };
|
||||||
|
active.set(scope, entry);
|
||||||
|
schedule(entry);
|
||||||
|
},
|
||||||
|
settled(scope: string) {
|
||||||
|
const entry = active.get(scope);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.dueAt = Date.now() + interval;
|
||||||
|
schedule(entry);
|
||||||
|
},
|
||||||
|
deactivate(scope: string) {
|
||||||
|
clearTimeout(active.get(scope)?.timer);
|
||||||
|
active.delete(scope);
|
||||||
|
if (active.size === 0 && typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
createRemoteExecutorStatusResource,
|
createRemoteExecutorStatusResource,
|
||||||
|
remoteExecutorStatusScope,
|
||||||
type RemoteExecutorStatusData,
|
type RemoteExecutorStatusData,
|
||||||
type RemoteExecutorStatusRequest,
|
type RemoteExecutorStatusRequest,
|
||||||
} from "./remote-executor-status-resource";
|
} from "./remote-executor-status-resource";
|
||||||
@@ -117,3 +118,159 @@ describe("remote executor status resource", () => {
|
|||||||
expect(requester).toHaveBeenCalledTimes(130);
|
expect(requester).toHaveBeenCalledTimes(130);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// @covers AC-EXECUTORS-TASK-STATUS-001.1 and AC-EXECUTORS-TASK-STATUS-001.2
|
||||||
|
describe("mounted status refresh", () => {
|
||||||
|
it("refreshes shared consumers without interaction and stops after the last unsubscribe", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const requester = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(healthyStatus("old"))
|
||||||
|
.mockResolvedValue(healthyStatus("new"));
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const scope = remoteExecutorStatusScope(REQUEST);
|
||||||
|
const first = resource.subscribe(scope, () => undefined);
|
||||||
|
const second = resource.subscribe(scope, () => undefined);
|
||||||
|
try {
|
||||||
|
await resource.load(REQUEST);
|
||||||
|
first();
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_name).toBe("new");
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
second();
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
first();
|
||||||
|
second();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries unavailable transport and failed reads automatically", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const requester = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(null)
|
||||||
|
.mockRejectedValueOnce(new Error("secret"))
|
||||||
|
.mockResolvedValue(healthyStatus("recovered"));
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const scope = remoteExecutorStatusScope(REQUEST);
|
||||||
|
const stop = resource.subscribe(scope, () => undefined);
|
||||||
|
try {
|
||||||
|
await resource.load(REQUEST);
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_status_error).toBe(
|
||||||
|
"Remote executor status is unavailable.",
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_name).toBe("recovered");
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pauses hidden documents and refreshes expired scopes on return", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const requester = vi.fn().mockResolvedValue(healthyStatus("pod"));
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
|
||||||
|
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||||
|
try {
|
||||||
|
await resource.load(REQUEST);
|
||||||
|
visibility.mockReturnValue("hidden");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(1);
|
||||||
|
visibility.mockReturnValue("visible");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
visibility.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("refresh lifecycle cleanup", () => {
|
||||||
|
it("joins an in-flight refresh and resets the schedule after settlement", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolve!: (value: RemoteExecutorStatusData) => void;
|
||||||
|
const requester = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(healthyStatus("initial"))
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<RemoteExecutorStatusData>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValue(healthyStatus("latest"));
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const scope = remoteExecutorStatusScope(REQUEST);
|
||||||
|
const stop = resource.subscribe(scope, () => undefined);
|
||||||
|
try {
|
||||||
|
await resource.load(REQUEST);
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
const pending = resource.load(REQUEST, true);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_name).toBe("initial");
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
resolve(healthyStatus("updated"));
|
||||||
|
await pending;
|
||||||
|
await vi.advanceTimersByTimeAsync(89_999);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_name).toBe("latest");
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves fresh results on visibility return and detaches its listener on cleanup", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const requester = vi.fn().mockResolvedValue(healthyStatus("pod"));
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
|
||||||
|
const visibility = vi.spyOn(document, "visibilityState", "get");
|
||||||
|
const removed = vi.spyOn(document, "removeEventListener");
|
||||||
|
try {
|
||||||
|
await resource.load(REQUEST);
|
||||||
|
visibility.mockReturnValue("hidden");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
visibility.mockReturnValue("visible");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
await vi.advanceTimersByTimeAsync(59_999);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(2);
|
||||||
|
stop();
|
||||||
|
expect(removed).toHaveBeenCalledWith("visibilitychange", expect.any(Function));
|
||||||
|
} finally {
|
||||||
|
stop();
|
||||||
|
visibility.mockRestore();
|
||||||
|
removed.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not restart polling when an abandoned read settles", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let resolve!: (value: RemoteExecutorStatusData) => void;
|
||||||
|
const requester = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<RemoteExecutorStatusData>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const resource = createRemoteExecutorStatusResource(requester);
|
||||||
|
const stop = resource.subscribe(remoteExecutorStatusScope(REQUEST), () => undefined);
|
||||||
|
const pending = resource.load(REQUEST);
|
||||||
|
stop();
|
||||||
|
resolve(healthyStatus("old scope"));
|
||||||
|
await pending;
|
||||||
|
await vi.advanceTimersByTimeAsync(180_000);
|
||||||
|
expect(requester).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRemoteExecutorStatusRefresh } from "./remote-executor-status-refresh";
|
||||||
import { getKubernetesTaskSession } from "@/lib/api/domains/kubernetes-api";
|
import { getKubernetesTaskSession } from "@/lib/api/domains/kubernetes-api";
|
||||||
import { t } from "@/lib/i18n";
|
import { t } from "@/lib/i18n";
|
||||||
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
|
import type { KubernetesSession } from "@/lib/types/http-kubernetes";
|
||||||
@@ -28,6 +29,7 @@ export type RemoteExecutorStatusSnapshot = {
|
|||||||
type Requester = (request: RemoteExecutorStatusRequest) => Promise<RemoteExecutorStatusData> | null;
|
type Requester = (request: RemoteExecutorStatusRequest) => Promise<RemoteExecutorStatusData> | null;
|
||||||
|
|
||||||
type Entry = {
|
type Entry = {
|
||||||
|
request?: RemoteExecutorStatusRequest;
|
||||||
snapshot: RemoteExecutorStatusSnapshot;
|
snapshot: RemoteExecutorStatusSnapshot;
|
||||||
promise: Promise<RemoteExecutorStatusSnapshot> | null;
|
promise: Promise<RemoteExecutorStatusSnapshot> | null;
|
||||||
listeners: Set<() => void>;
|
listeners: Set<() => void>;
|
||||||
@@ -77,12 +79,12 @@ function requestRemoteStatus(
|
|||||||
if (executorType === "k8s" && executorId) {
|
if (executorType === "k8s" && executorId) {
|
||||||
return getKubernetesTaskSession(executorId, taskId, sessionId).then(projectKubernetesStatus);
|
return getKubernetesTaskSession(executorId, taskId, sessionId).then(projectKubernetesStatus);
|
||||||
}
|
}
|
||||||
return (
|
const client = getWebSocketClient();
|
||||||
getWebSocketClient()?.request<RemoteExecutorStatusData>(
|
if (client?.getStatus() !== "connected") return null;
|
||||||
"task.session.status",
|
return client.request<RemoteExecutorStatusData>(
|
||||||
{ task_id: taskId, session_id: sessionId },
|
"task.session.status",
|
||||||
10000,
|
{ task_id: taskId, session_id: sessionId },
|
||||||
) ?? null
|
10000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,38 +106,50 @@ function notify(entry: Entry): void {
|
|||||||
entry.listeners.forEach((listener) => listener());
|
entry.listeners.forEach((listener) => listener());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function publishUnavailable(entry: Entry): void {
|
||||||
|
entry.snapshot = { status: statusError(), loading: false };
|
||||||
|
entry.failed = true;
|
||||||
|
entry.completedAt = 0;
|
||||||
|
notify(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(entries: Map<string, Entry>, current: Entry): void {
|
||||||
|
const candidates = [...entries.entries()]
|
||||||
|
.filter(([, entry]) => entry !== current && !entry.promise && entry.listeners.size === 0)
|
||||||
|
.sort((left, right) => left[1].lastUsed - right[1].lastUsed);
|
||||||
|
for (const [scope] of candidates) {
|
||||||
|
if (entries.size <= MAX_ENTRIES) break;
|
||||||
|
entries.delete(scope);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry(): Entry {
|
||||||
|
return {
|
||||||
|
snapshot: EMPTY_SNAPSHOT,
|
||||||
|
promise: null,
|
||||||
|
listeners: new Set(),
|
||||||
|
completedAt: 0,
|
||||||
|
failed: false,
|
||||||
|
lastUsed: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createRemoteExecutorStatusResource(requester: Requester = requestRemoteStatus) {
|
export function createRemoteExecutorStatusResource(requester: Requester = requestRemoteStatus) {
|
||||||
const entries = new Map<string, Entry>();
|
const entries = new Map<string, Entry>();
|
||||||
let usageCounter = 0;
|
let usageCounter = 0;
|
||||||
|
const refresh = createRemoteExecutorStatusRefresh(SUCCESS_FRESHNESS_MS);
|
||||||
|
|
||||||
function touch(entry: Entry): void {
|
function touch(entry: Entry): void {
|
||||||
entry.lastUsed = ++usageCounter;
|
entry.lastUsed = ++usageCounter;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prune(current: Entry): void {
|
|
||||||
const candidates = [...entries.entries()]
|
|
||||||
.filter(([, entry]) => entry !== current && !entry.promise && entry.listeners.size === 0)
|
|
||||||
.sort((left, right) => left[1].lastUsed - right[1].lastUsed);
|
|
||||||
for (const [scope] of candidates) {
|
|
||||||
if (entries.size <= MAX_ENTRIES) break;
|
|
||||||
entries.delete(scope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function entryFor(scope: string): Entry {
|
function entryFor(scope: string): Entry {
|
||||||
const existing = entries.get(scope);
|
const existing = entries.get(scope);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
touch(existing);
|
touch(existing);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
const entry: Entry = {
|
const entry = createEntry();
|
||||||
snapshot: EMPTY_SNAPSHOT,
|
|
||||||
promise: null,
|
|
||||||
listeners: new Set(),
|
|
||||||
completedAt: 0,
|
|
||||||
failed: false,
|
|
||||||
lastUsed: 0,
|
|
||||||
};
|
|
||||||
entries.set(scope, entry);
|
entries.set(scope, entry);
|
||||||
touch(entry);
|
touch(entry);
|
||||||
return entry;
|
return entry;
|
||||||
@@ -146,14 +160,21 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
|
|||||||
force = false,
|
force = false,
|
||||||
): Promise<RemoteExecutorStatusSnapshot> | null {
|
): Promise<RemoteExecutorStatusSnapshot> | null {
|
||||||
if (!isValidRemoteExecutorStatusRequest(request)) return null;
|
if (!isValidRemoteExecutorStatusRequest(request)) return null;
|
||||||
const entry = entryFor(remoteExecutorStatusScope(request));
|
const scope = remoteExecutorStatusScope(request);
|
||||||
|
const entry = entryFor(scope);
|
||||||
|
entry.request = request;
|
||||||
|
if (entry.listeners.size) activate(scope, entry);
|
||||||
if (entry.promise) return entry.promise;
|
if (entry.promise) return entry.promise;
|
||||||
const successIsFresh = Date.now() - entry.completedAt < SUCCESS_FRESHNESS_MS;
|
const successIsFresh = Date.now() - entry.completedAt < SUCCESS_FRESHNESS_MS;
|
||||||
if (!force && entry.snapshot.status && !entry.failed && successIsFresh) {
|
if (!force && entry.snapshot.status && !entry.failed && successIsFresh) {
|
||||||
return Promise.resolve(entry.snapshot);
|
return Promise.resolve(entry.snapshot);
|
||||||
}
|
}
|
||||||
const pending = requester(request);
|
const pending = requester(request);
|
||||||
if (!pending) return null;
|
if (!pending) {
|
||||||
|
publishUnavailable(entry);
|
||||||
|
refresh.settled(scope);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
entry.snapshot = { status: entry.snapshot.status, loading: true };
|
entry.snapshot = { status: entry.snapshot.status, loading: true };
|
||||||
notify(entry);
|
notify(entry);
|
||||||
const promise = pending
|
const promise = pending
|
||||||
@@ -173,16 +194,29 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
|
|||||||
entry.completedAt = failed ? 0 : Date.now();
|
entry.completedAt = failed ? 0 : Date.now();
|
||||||
touch(entry);
|
touch(entry);
|
||||||
notify(entry);
|
notify(entry);
|
||||||
prune(entry);
|
prune(entries, entry);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (entry.promise === promise) entry.promise = null;
|
if (entry.promise === promise) entry.promise = null;
|
||||||
|
refresh.settled(scope);
|
||||||
});
|
});
|
||||||
entry.promise = promise;
|
entry.promise = promise;
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activate(scope: string, entry: Entry) {
|
||||||
|
if (!entry.request) return;
|
||||||
|
const request = entry.request;
|
||||||
|
refresh.activate(
|
||||||
|
scope,
|
||||||
|
() => void load(request, true),
|
||||||
|
entry.completedAt
|
||||||
|
? entry.completedAt + SUCCESS_FRESHNESS_MS
|
||||||
|
: Date.now() + SUCCESS_FRESHNESS_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getSnapshot(scope: string): RemoteExecutorStatusSnapshot {
|
getSnapshot(scope: string): RemoteExecutorStatusSnapshot {
|
||||||
if (!scope) return EMPTY_SNAPSHOT;
|
if (!scope) return EMPTY_SNAPSHOT;
|
||||||
@@ -196,7 +230,11 @@ export function createRemoteExecutorStatusResource(requester: Requester = reques
|
|||||||
if (!scope) return () => undefined;
|
if (!scope) return () => undefined;
|
||||||
const entry = entryFor(scope);
|
const entry = entryFor(scope);
|
||||||
entry.listeners.add(listener);
|
entry.listeners.add(listener);
|
||||||
return () => entry.listeners.delete(listener);
|
activate(scope, entry);
|
||||||
|
return () => {
|
||||||
|
entry.listeners.delete(listener);
|
||||||
|
if (entry.listeners.size === 0) refresh.deactivate(scope);
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createRemoteExecutorStatusResource,
|
||||||
|
remoteExecutorStatusScope,
|
||||||
|
} from "./remote-executor-status-resource";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getStatus: vi.fn(() => "disconnected"),
|
||||||
|
request: vi.fn().mockResolvedValue({ remote_state: "running" }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => mocks }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remote executor status transport", () => {
|
||||||
|
it("does not queue offline polling reads and resumes after reconnect", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const request = {
|
||||||
|
executorType: "sprites",
|
||||||
|
executorId: "executor",
|
||||||
|
taskId: "task",
|
||||||
|
sessionId: "session",
|
||||||
|
};
|
||||||
|
const resource = createRemoteExecutorStatusResource();
|
||||||
|
const scope = remoteExecutorStatusScope(request);
|
||||||
|
const unsubscribe = resource.subscribe(scope, () => undefined);
|
||||||
|
try {
|
||||||
|
resource.load(request);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_status_error).toBe(
|
||||||
|
"Remote executor status is unavailable.",
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(270_000);
|
||||||
|
expect(mocks.request).not.toHaveBeenCalled();
|
||||||
|
expect(resource.getSnapshot(scope).loading).toBe(false);
|
||||||
|
|
||||||
|
mocks.getStatus.mockReturnValue("connected");
|
||||||
|
await vi.advanceTimersByTimeAsync(90_000);
|
||||||
|
expect(mocks.request).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resource.getSnapshot(scope).status?.remote_state).toBe("running");
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
created: 2026-09-19
|
||||||
|
status: complete
|
||||||
|
requirements:
|
||||||
|
- REQ-EXECUTORS-TASK-STATUS-001
|
||||||
|
system_design:
|
||||||
|
- ../../specs/executors/system-design/task-status-indicators.md
|
||||||
|
legacy_specs:
|
||||||
|
- ../../specs/kubernetes-executor/spec.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Implementation Plan: Sidebar executor status
|
||||||
|
|
||||||
|
## Overview and evidence
|
||||||
|
|
||||||
|
Source tracing establishes two defects: `useRemoteExecutorStatus` loads on mount
|
||||||
|
or identity change only, and the resource has no timer or visibility listener.
|
||||||
|
Hover/focus explicitly refreshes, explaining a mounted icon staying stale until
|
||||||
|
interaction. `StatusTrigger` destructures a fixed prop list and drops the Radix
|
||||||
|
DOM ref and injected properties. The component tests mock Tooltip/Drawer as
|
||||||
|
fragments, so they do not verify anchoring or real trigger linkage. This is a
|
||||||
|
confirmed composition defect; its exact rendered symptom still requires E2E.
|
||||||
|
At design time frontend dependencies were absent. Implementation installed them
|
||||||
|
and verified the real primitive linkage and browser behavior below.
|
||||||
|
|
||||||
|
The earlier completed
|
||||||
|
[preview repair](../kubernetes-executor-task-status-preview-repair/plan.md)
|
||||||
|
introduced eager hydration and explicitly excluded periodic polling. This package
|
||||||
|
supersedes that exclusion for mounted consumers in visible documents and repairs
|
||||||
|
primitive composition. Its historical test results are not evidence for this fix.
|
||||||
|
|
||||||
|
## Scope and technical approach
|
||||||
|
|
||||||
|
One sequential vertical work order repairs the shared resource lifecycle and
|
||||||
|
trigger composition, adds real-primitive and lifecycle regressions, and verifies
|
||||||
|
desktop/mobile flows. Reuse existing summaries, localized labels, exact-scope
|
||||||
|
requests, and safe error projection. No new API, executor lifecycle behavior,
|
||||||
|
settings UI or unrelated tooltip refactor is in scope.
|
||||||
|
|
||||||
|
## ASCII UI preview
|
||||||
|
|
||||||
|
UI-01: Sidebar indicator, desktop hover/focus (AC .1/.3).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task title [executor glyph]
|
||||||
|
+---------------------------+
|
||||||
|
| Pod / executor name |
|
||||||
|
| State Running |
|
||||||
|
| Restarts 0 |
|
||||||
|
| Workspace Persistent |
|
||||||
|
| Last check just now |
|
||||||
|
+---------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
UI-02: Phone task-list indicator, tapped (AC .4).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task list remains behind drawer
|
||||||
|
+--------------------------------+
|
||||||
|
| Executor name [X] | fixed header
|
||||||
|
| State / restarts / workspace | scrolling body
|
||||||
|
| Created / last check |
|
||||||
|
| Error or updating, when needed |
|
||||||
|
+--------------------------------+
|
||||||
|
safe area
|
||||||
|
```
|
||||||
|
|
||||||
|
The anchored desktop surface and short touch drawer are required. Labels and
|
||||||
|
spacing are illustrative and localized. Loading retains facts; failures use a
|
||||||
|
text row. Preserve dense desktop row height and a non-overlapping 44 px touch
|
||||||
|
hit area. No hover is required for ongoing status updates.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
AC .1/.2: extend `remote-executor-status-resource.test.ts` with fake-clock cases
|
||||||
|
for no-interaction refresh, sharing, failed reads, absent transport, visibility,
|
||||||
|
unmount and obsolete scope settlement. Assert request counts as well as outcomes.
|
||||||
|
AC .3/.4: new `remote-cloud-tooltip-primitives.test.tsx` uses actual TooltipProvider,
|
||||||
|
Tooltip and Drawer; asserts trigger aria linkage, focus opening, Escape, content
|
||||||
|
and focus return. Existing mocked tests remain useful for summary projections.
|
||||||
|
|
||||||
|
## E2E tests
|
||||||
|
|
||||||
|
Extend existing `settings/kubernetes-task-environment.spec.ts` and
|
||||||
|
`settings/mobile-kubernetes-task-environment.spec.ts`. Change the exact-session
|
||||||
|
response while mounted, advance controlled time, await the causal request, and
|
||||||
|
assert tone before pointer interaction. Desktop hover/focus must show a panel
|
||||||
|
anchored near the icon, inside the viewport. Mobile taps must open the drawer,
|
||||||
|
show the same facts and preserve task selection on dismissal. Verify hit area,
|
||||||
|
adjacent controls, internal scroll, safe area and no horizontal overflow.
|
||||||
|
|
||||||
|
## Work orders
|
||||||
|
|
||||||
|
- [x] [Task 01: Refresh and disclose executor status](task-01-refresh-and-disclose.md)
|
||||||
|
|
||||||
|
## Verification commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd apps && pnpm install --frozen-lockfile)
|
||||||
|
(cd apps/web && pnpm exec vitest run hooks/domains/session/remote-executor-status-transport.test.ts hooks/domains/session/remote-executor-status-resource.test.ts components/task/remote-cloud-tooltip.test.tsx components/task/remote-cloud-tooltip-primitives.test.tsx)
|
||||||
|
(cd apps/web && NODE_OPTIONS=--max-old-space-size=3072 pnpm run typecheck)
|
||||||
|
(cd apps/web && pnpm run i18n:check)
|
||||||
|
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --project=chromium tests/settings/kubernetes-task-environment.spec.ts -- --retries=0)
|
||||||
|
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --no-build --project=mobile-chrome tests/settings/mobile-kubernetes-task-environment.spec.ts -- --retries=0)
|
||||||
|
python3 scripts/list-docs.py validate
|
||||||
|
python3 scripts/lint-spec-files.py --all
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
Implementation and targeted verification complete.
|
||||||
|
|
||||||
|
- RED: no-interaction refresh, automatic retry, and visibility-return tests
|
||||||
|
failed on stale/missing status. The real Tooltip regression failed because
|
||||||
|
the DOM trigger had no `aria-describedby` linkage.
|
||||||
|
- GREEN: the listed focused Vitest command passes 29 tests in 4 files.
|
||||||
|
- Changed-file ESLint with `--max-warnings=0` passes.
|
||||||
|
- Fresh managed production build passes. Desktop Chromium E2E: 1/1, 4.3 seconds;
|
||||||
|
mobile Chrome E2E: 1/1, 5.7 seconds; both with `--retries=0`. These verify
|
||||||
|
automatic updates before interaction, anchored desktop details, touch drawer
|
||||||
|
facts, no task selection, focus return, and horizontal containment.
|
||||||
|
- `pnpm run i18n:check` passes. Public-doc validation passes for 47 pages.
|
||||||
|
Catalog validation, specification lint, and `git diff --check` pass.
|
||||||
|
- Full `pnpm run typecheck` could not complete: Node exhausted both its default
|
||||||
|
2 GB heap and a 3 GB heap in this 4 GB environment. This remains an explicit
|
||||||
|
full-project validation limitation, not a passing check.
|
||||||
|
- Focused typecheck passes with the repository tsconfig, changed production/test
|
||||||
|
roots, actual transitive dependencies, web ambient declaration files, and
|
||||||
|
explicit Node/React ambient types. It used `/tmp/executor-status-tsconfig.json`
|
||||||
|
with incremental checking disabled and a 3 GB heap. No compiler errors were
|
||||||
|
suppressed. This does not replace full-project typecheck evidence.
|
||||||
|
- Ready PR delivery was explicitly requested after user testing. Full-project
|
||||||
|
typecheck remains an explicit validation limitation for CI.
|
||||||
|
Managed E2E instances and the isolated user preview were stopped; the main
|
||||||
|
instance was untouched. Generated typecheck crash dumps were removed.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- Polling scales with distinct mounted scopes; share requests, stop on hidden
|
||||||
|
documents/unmount, and preserve existing cache bounds.
|
||||||
|
- Real primitives may expose additional focus/portal issues hidden by mocks.
|
||||||
|
- Browser throttling can delay timers; returning to visibility must recover.
|
||||||
|
|
||||||
|
## Public documentation
|
||||||
|
|
||||||
|
At implementation, update `docs/public/k8s.md` and `docs/public/executors.md`
|
||||||
|
with automatic refresh while visible. Preserve their how-to/reference structure.
|
||||||
|
No public claim changes during this design-only turn.
|
||||||
|
|
||||||
|
### PR review remediation
|
||||||
|
|
||||||
|
Greptile identified obsolete queued WebSocket reads during outages. The default
|
||||||
|
requester now treats a disconnected client as unavailable transport. The new
|
||||||
|
transport regression failed with four offline requests before the guard, then
|
||||||
|
passed with zero offline reads and a healthy result after reconnect. All 27
|
||||||
|
focused tests and changed-file ESLint pass. No UI, API or public-doc contract
|
||||||
|
changed; the system design records the transport guard. Remote CI/review
|
||||||
|
verification remains pending for the remediation commit.
|
||||||
|
|
||||||
|
CodeRabbit summary findings: unavailable transport now immediately publishes the
|
||||||
|
sanitized unavailable snapshot; primitive classes and touch handlers are
|
||||||
|
composed. The unavailable-state assertion failed before the fix. Actual-drawer
|
||||||
|
coverage now exercises click, Enter and Space, with no task selection. All 29
|
||||||
|
focused tests and changed-file lint pass; mobile E2E passes 1/1 without retries
|
||||||
|
after a fresh build. Desktop E2E also passes 1/1 without retries, and the
|
||||||
|
focused TypeScript check passes.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
id: "01-refresh-and-disclose"
|
||||||
|
title: "Refresh and disclose executor status"
|
||||||
|
status: done
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
plan: "plan.md"
|
||||||
|
requirements:
|
||||||
|
- REQ-EXECUTORS-TASK-STATUS-001
|
||||||
|
acceptance_criteria:
|
||||||
|
- AC-EXECUTORS-TASK-STATUS-001.1
|
||||||
|
- AC-EXECUTORS-TASK-STATUS-001.2
|
||||||
|
- AC-EXECUTORS-TASK-STATUS-001.3
|
||||||
|
- AC-EXECUTORS-TASK-STATUS-001.4
|
||||||
|
system_design:
|
||||||
|
- ../../specs/executors/system-design/task-status-indicators.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task 01: Refresh and disclose executor status
|
||||||
|
|
||||||
|
## Summary and scope
|
||||||
|
|
||||||
|
Implement the [plan](plan.md) as one sequential vertical repair. Own refresh
|
||||||
|
lifecycle, primitive composition, regression coverage and public documentation.
|
||||||
|
Exclude backend/API changes, executor lifecycle and unrelated tooltip consumers.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
1. Fake-clock tests first fail on the missing automatic refresh, then pass with
|
||||||
|
one lifecycle per exact scope, including failures, visibility and cleanup.
|
||||||
|
2. Real-primitive regression first fails on missing trigger linkage; after the
|
||||||
|
correction desktop details anchor to the glyph and mobile focus returns.
|
||||||
|
3. Desktop/mobile E2E prove fresh status without hover, visible details and
|
||||||
|
touch containment without accidental task selection.
|
||||||
|
|
||||||
|
## ASCII UI preview
|
||||||
|
|
||||||
|
UI-01: Sidebar indicator, desktop hover/focus (AC .1/.3).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task title [executor glyph]
|
||||||
|
+---------------------------+
|
||||||
|
| Pod / executor name |
|
||||||
|
| State Running |
|
||||||
|
| Restarts 0 |
|
||||||
|
| Workspace Persistent |
|
||||||
|
| Last check just now |
|
||||||
|
+---------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
UI-02: Phone task-list indicator, tapped (AC .4).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Task list remains behind drawer
|
||||||
|
+--------------------------------+
|
||||||
|
| Executor name [X] | fixed header
|
||||||
|
| State / restarts / workspace | scrolling body
|
||||||
|
| Created / last check |
|
||||||
|
| Error or updating, when needed |
|
||||||
|
+--------------------------------+
|
||||||
|
safe area
|
||||||
|
```
|
||||||
|
|
||||||
|
The anchored desktop surface and short touch drawer are required. Labels and
|
||||||
|
spacing are illustrative and localized. Loading retains facts; failures use a
|
||||||
|
text row. Preserve dense desktop row height and a non-overlapping 44 px touch
|
||||||
|
hit area. No hover is required for ongoing status updates.
|
||||||
|
|
||||||
|
Full composition and evidence: [plan](plan.md#ascii-ui-preview).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Run tests red before production edits, then all checks below after correction.
|
||||||
|
The new primitive test file is created by this work order.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
(cd apps && pnpm install --frozen-lockfile)
|
||||||
|
(cd apps/web && pnpm exec vitest run hooks/domains/session/remote-executor-status-transport.test.ts hooks/domains/session/remote-executor-status-resource.test.ts components/task/remote-cloud-tooltip.test.tsx components/task/remote-cloud-tooltip-primitives.test.tsx)
|
||||||
|
(cd apps/web && NODE_OPTIONS=--max-old-space-size=3072 pnpm run typecheck)
|
||||||
|
(cd apps/web && pnpm run i18n:check)
|
||||||
|
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --project=chromium tests/settings/kubernetes-task-environment.spec.ts -- --retries=0)
|
||||||
|
(cd apps/web && PATH=/usr/local/go/bin:$PATH pnpm e2e:run --host --no-build --project=mobile-chrome tests/settings/mobile-kubernetes-task-environment.spec.ts -- --retries=0)
|
||||||
|
python3 scripts/list-docs.py validate
|
||||||
|
python3 scripts/lint-spec-files.py --all
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files likely touched
|
||||||
|
|
||||||
|
- `apps/web/hooks/domains/session/remote-executor-status-resource.ts` and `.test.ts`
|
||||||
|
- `apps/web/hooks/domains/session/remote-executor-status-refresh.ts` (new scheduler)
|
||||||
|
- `apps/web/components/task/remote-cloud-tooltip.tsx` and `.test.tsx`
|
||||||
|
- `apps/web/components/task/remote-cloud-tooltip-primitives.test.tsx` (new)
|
||||||
|
- `apps/web/e2e/tests/settings/kubernetes-task-environment.spec.ts`
|
||||||
|
- `apps/web/e2e/tests/settings/mobile-kubernetes-task-environment.spec.ts`
|
||||||
|
- `docs/public/k8s.md` and `docs/public/executors.md`
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None. Install workspace dependencies before testing.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
Increased reads for many scopes; strict unsubscribe/visibility cleanup is required.
|
||||||
|
Do not replace real primitives with mocks in the composition regression.
|
||||||
|
|
||||||
|
## Parallelism
|
||||||
|
|
||||||
|
`sequential`
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
The requirement and design above, current shared-resource tests, task PR tooltip
|
||||||
|
interaction pattern, and mobile-menu-sheet layout exemplar.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
Implementation and targeted verification complete.
|
||||||
|
|
||||||
|
- RED: no-interaction refresh, automatic retry, and visibility-return tests
|
||||||
|
failed on stale/missing status. The real Tooltip regression failed because
|
||||||
|
the DOM trigger had no `aria-describedby` linkage.
|
||||||
|
- GREEN: the listed focused Vitest command passes 29 tests in 4 files.
|
||||||
|
- Changed-file ESLint with `--max-warnings=0` passes.
|
||||||
|
- Fresh managed production build passes. Desktop Chromium E2E: 1/1, 4.3 seconds;
|
||||||
|
mobile Chrome E2E: 1/1, 5.7 seconds; both with `--retries=0`. These verify
|
||||||
|
automatic updates before interaction, anchored desktop details, touch drawer
|
||||||
|
facts, no task selection, focus return, and horizontal containment.
|
||||||
|
- `pnpm run i18n:check` passes. Public-doc validation passes for 47 pages.
|
||||||
|
Catalog validation, specification lint, and `git diff --check` pass.
|
||||||
|
- Full `pnpm run typecheck` could not complete: Node exhausted both its default
|
||||||
|
2 GB heap and a 3 GB heap in this 4 GB environment. This remains an explicit
|
||||||
|
full-project validation limitation, not a passing check.
|
||||||
|
- Focused typecheck passes with the repository tsconfig, changed production/test
|
||||||
|
roots, actual transitive dependencies, web ambient declaration files, and
|
||||||
|
explicit Node/React ambient types. It used `/tmp/executor-status-tsconfig.json`
|
||||||
|
with incremental checking disabled and a 3 GB heap. No compiler errors were
|
||||||
|
suppressed. This does not replace full-project typecheck evidence.
|
||||||
|
- Ready PR delivery was explicitly requested after user testing. Full-project
|
||||||
|
typecheck remains an explicit validation limitation for CI.
|
||||||
|
Managed E2E instances and the isolated user preview were stopped; the main
|
||||||
|
instance was untouched. Generated typecheck crash dumps were removed.
|
||||||
|
|
||||||
|
### PR review remediation
|
||||||
|
|
||||||
|
Greptile identified obsolete queued WebSocket reads during outages. The default
|
||||||
|
requester now treats a disconnected client as unavailable transport. The new
|
||||||
|
transport regression failed with four offline requests before the guard, then
|
||||||
|
passed with zero offline reads and a healthy result after reconnect. All 27
|
||||||
|
focused tests and changed-file ESLint pass. No UI, API or public-doc contract
|
||||||
|
changed; the system design records the transport guard. Remote CI/review
|
||||||
|
verification remains pending for the remediation commit.
|
||||||
|
|
||||||
|
CodeRabbit summary findings: unavailable transport now immediately publishes the
|
||||||
|
sanitized unavailable snapshot; primitive classes and touch handlers are
|
||||||
|
composed. The unavailable-state assertion failed before the fix. Actual-drawer
|
||||||
|
coverage now exercises click, Enter and Space, with no task selection. All 29
|
||||||
|
focused tests and changed-file lint pass; mobile E2E passes 1/1 without retries
|
||||||
|
after a fresh build. Desktop E2E also passes 1/1 without retries, and the
|
||||||
|
focused TypeScript check passes.
|
||||||
@@ -325,7 +325,7 @@ Choose **Settings > Executors > Kubernetes**. Only an administrator can create,
|
|||||||
|
|
||||||
Opening a saved Kubernetes profile puts the shared cluster connection editor, connection test, and executor-wide active sessions before workload settings. The test uses current unsaved connection and profile values. Administrators edit both resources through one Save/Reset flow, while members see the same hierarchy read-only. Configured executor rows and task settings icons open the selected profile directly; the standalone connection route remains only for an executor with no profiles.
|
Opening a saved Kubernetes profile puts the shared cluster connection editor, connection test, and executor-wide active sessions before workload settings. The test uses current unsaved connection and profile values. Administrators edit both resources through one Save/Reset flow, while members see the same hierarchy read-only. Configured executor rows and task settings icons open the selected profile directly; the standalone connection route remains only for an executor with no profiles.
|
||||||
|
|
||||||
Kubernetes Pod glyphs on Kanban cards and in task lists hydrate from the exact task/session status when they render, before any hover. Fine-pointer hover or keyboard focus shows a compact structured Pod summary; touch opens the same summary in a bottom Drawer without activating the task row. Duplicate indicators for the same session share the current read instead of issuing parallel requests.
|
Kubernetes Pod glyphs on Kanban cards and in task lists hydrate from the exact task/session status when they render, before any hover. While the page is visible, mounted indicators refresh automatically every 90 seconds and retry failed reads. Fine-pointer hover or keyboard focus shows a compact structured Pod summary; touch opens the same summary in a bottom Drawer without activating the task row. Duplicate indicators for the same session share the current read instead of issuing parallel requests.
|
||||||
|
|
||||||
Each executor fixes one namespace and connection configuration. Each profile supplies one strict `core/v1` `PodTemplate`, a main-container name, `linux/amd64` or `linux/arm64`, and one workspace mode:
|
Each executor fixes one namespace and connection configuration. Each profile supplies one strict `core/v1` `PodTemplate`, a main-container name, `linux/amd64` or `linux/arm64`, and one workspace mode:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ The **Active sessions** card refreshes every 90 seconds and reads only Kandev's
|
|||||||
|
|
||||||
On a Kubernetes task, use the Pod control in the task header to inspect the exact authorized Pod's live phase, container state, restart count, workspace mode, creation time, and sanitized failure reason. Desktop opens this disclosure on hover; touch and coarse-pointer layouts provide a named 44 px button that opens a Drawer with the same structured Pod summary. Quiet Refresh and Settings icons sit in the summary header. Refresh visibly stays busy until the immediate status read settles, and Settings opens the exact selected profile. Kubernetes does not show the generic **Reset environment** action because Pod and PVC lifecycle remains owned by Stop, Resume, Archive, and Delete.
|
On a Kubernetes task, use the Pod control in the task header to inspect the exact authorized Pod's live phase, container state, restart count, workspace mode, creation time, and sanitized failure reason. Desktop opens this disclosure on hover; touch and coarse-pointer layouts provide a named 44 px button that opens a Drawer with the same structured Pod summary. Quiet Refresh and Settings icons sit in the summary header. Refresh visibly stays busy until the immediate status read settles, and Settings opens the exact selected profile. Kubernetes does not show the generic **Reset environment** action because Pod and PVC lifecycle remains owned by Stop, Resume, Archive, and Delete.
|
||||||
|
|
||||||
The smaller Pod glyphs on Kanban cards and in task lists request their exact session status as soon as they appear, so their healthy or failed color does not depend on a first hover. On a fine pointer, hover or keyboard focus opens a compact summary with the Pod identity and aligned rows for state, restarts, workspace mode, creation time, last check, and any sanitized failure. On touch layouts, the same glyph has an expanded 44 px hit target and opens that summary in a bottom Drawer without selecting the task row.
|
The smaller Pod glyphs on Kanban cards and in task lists request their exact session status as soon as they appear, so their healthy or failed color does not depend on a first hover. While the page is visible, mounted indicators refresh automatically every 90 seconds and retry failed reads. On a fine pointer, hover or keyboard focus opens a compact summary with the Pod identity and aligned rows for state, restarts, workspace mode, creation time, last check, and any sanitized failure. On touch layouts, the same glyph has an expanded 44 px hit target and opens that summary in a bottom Drawer without selecting the task row.
|
||||||
|
|
||||||
### Understand template ownership
|
### Understand template ownership
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,8 @@ replace the foundation's resource ownership, recovery, or cleanup rules.
|
|||||||
|
|
||||||
- [Agents](../agents/README.md): supplies the agent command and profile.
|
- [Agents](../agents/README.md): supplies the agent command and profile.
|
||||||
- [Tasks](../tasks/README.md): owns task-scoped execution lifecycle.
|
- [Tasks](../tasks/README.md): owns task-scoped execution lifecycle.
|
||||||
|
|
||||||
|
The compact task indicator contract is extracted into
|
||||||
|
[requirements](requirements/task-status-indicators.md) and
|
||||||
|
[design](system-design/task-status-indicators.md), including automatic freshness
|
||||||
|
and desktop/touch disclosure. The foundation retains task-page controls.
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
status: active
|
||||||
|
system: executors
|
||||||
|
created: 2026-09-19
|
||||||
|
owners:
|
||||||
|
- kandev
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task executor status indicators
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Executor indicators let users inspect the current execution environment without
|
||||||
|
opening a task. Executors owns the status contract; task lists consume it.
|
||||||
|
This document extracts the compact indicator contract from the
|
||||||
|
[Kubernetes foundation](../../kubernetes-executor/spec.md) and extends it with
|
||||||
|
continued automatic refresh. Task-page executor controls remain in that foundation.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### REQ-EXECUTORS-TASK-STATUS-001: Fresh, inspectable task indicators
|
||||||
|
|
||||||
|
Sidebar and Kanban remote executor indicators shall expose current exact-session
|
||||||
|
status without requiring pointer interaction.
|
||||||
|
|
||||||
|
#### Acceptance criteria
|
||||||
|
|
||||||
|
- **AC-EXECUTORS-TASK-STATUS-001.1:** A valid rendered indicator shall read its
|
||||||
|
exact executor/task/session status before hover. While the document is visible
|
||||||
|
and the indicator remains mounted, it shall start another read within 90 seconds
|
||||||
|
of the previous read settling, without user interaction. Returning to a visible
|
||||||
|
document shall refresh an expired result. Timing excludes browser suspension.
|
||||||
|
- **AC-EXECUTORS-TASK-STATUS-001.2:** Duplicate indicators for the same exact scope
|
||||||
|
shall share an in-flight read and recent result. Invalid scope shall issue no
|
||||||
|
request and show unavailable. A changed session shall never display a late
|
||||||
|
result belonging to its predecessor. Externally supplied status remains authoritative.
|
||||||
|
- **AC-EXECUTORS-TASK-STATUS-001.3:** Hover or keyboard focus shall open an anchored,
|
||||||
|
viewport-contained details surface and refresh status, joining any in-flight
|
||||||
|
read. Escape shall dismiss it. The summary shall identify the executor and show
|
||||||
|
labelled state, creation and check times, plus Kubernetes restart count and
|
||||||
|
workspace mode when available. Errors shall be sanitized, visible as text, and
|
||||||
|
retry automatically; loading shall retain previously known facts.
|
||||||
|
- **AC-EXECUTORS-TASK-STATUS-001.4:** Touch users shall open the same facts in a
|
||||||
|
bounded bottom drawer through an effective target of at least 44 px, without
|
||||||
|
selecting or opening the underlying task. The drawer shall support dismissal,
|
||||||
|
focus return, internal scrolling, and bottom safe-area clearance.
|
||||||
|
|
||||||
|
## Exclusions
|
||||||
|
|
||||||
|
No executor lifecycle changes, new backend APIs, persisted live status, credential
|
||||||
|
changes, or continuous reads while the document is hidden or no consumer remains.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
status: current
|
||||||
|
system: executors
|
||||||
|
requirements:
|
||||||
|
- REQ-EXECUTORS-TASK-STATUS-001
|
||||||
|
---
|
||||||
|
|
||||||
|
# Task executor status indicators
|
||||||
|
|
||||||
|
## Boundaries and mapping
|
||||||
|
|
||||||
|
REQ-EXECUTORS-TASK-STATUS-001 maps to the shared resource lifecycle (AC .1/.2),
|
||||||
|
responsive disclosure (AC .3/.4), and failure handling below. The existing
|
||||||
|
Kubernetes foundation retains API authorization and resource ownership.
|
||||||
|
|
||||||
|
## Shared resource lifecycle
|
||||||
|
|
||||||
|
Extend `apps/web/hooks/domains/session/remote-executor-status-resource.ts` and
|
||||||
|
`use-remote-executor-status.ts`. Keep the scope tuple of executor type, executor
|
||||||
|
ID, task ID and session ID, stable snapshots, in-flight deduplication, 90-second
|
||||||
|
successful-result reuse, and bounded eviction of unused entries.
|
||||||
|
|
||||||
|
The resource owns one refresh timer per actively subscribed valid scope, not one
|
||||||
|
per mounted icon. Start an eager read on subscription through the hook. Schedule
|
||||||
|
subsequent reads 90 seconds after settlement, including failures and unavailable
|
||||||
|
transport. An explicit disclosure refresh joins a pending request or starts a new
|
||||||
|
one and resets the schedule after settlement. Do not create overlapping reads.
|
||||||
|
On last unsubscribe cancel the timer. Late settlement may update its old cache
|
||||||
|
entry but must not resurrect polling. Hidden documents pause scheduled reads;
|
||||||
|
visibility restoration loads expired active scopes and reschedules fresh ones.
|
||||||
|
Manage the visibility listener only while active consumers exist. No new timer
|
||||||
|
for the authoritative external-status path or an invalid scope.
|
||||||
|
|
||||||
|
Retain `getKubernetesTaskSession` for Kubernetes and `task.session.status` for
|
||||||
|
other remote executors. A disconnected WebSocket is unavailable transport: skip
|
||||||
|
the read, publish sanitized unavailable status immediately, and retry on the
|
||||||
|
existing schedule without queuing obsolete requests.
|
||||||
|
No backend schema or persistence change is required.
|
||||||
|
|
||||||
|
## Responsive disclosure
|
||||||
|
|
||||||
|
`RemoteCloudTooltip` retains the existing summary and `useTaskIconTooltipState`.
|
||||||
|
`StatusTrigger` must forward the DOM ref and all primitive-injected DOM props to
|
||||||
|
its span, preserving composed event handlers, aria attributes and positioning
|
||||||
|
attributes. Use the repository React version's supported ref pattern. Test the
|
||||||
|
real `@kandev/ui` Tooltip and Drawer rather than replacing them with fragments.
|
||||||
|
Keep Escape dismissal and hover/focus behavior consistent with task PR indicators.
|
||||||
|
|
||||||
|
Desktop retains the small glyph and bounded anchored summary. On coarse pointers,
|
||||||
|
`useTouchDrawer` selects the existing short, temporary bottom disclosure. The
|
||||||
|
nearest layout exemplar is `components/kanban/mobile-menu-sheet.tsx`: fixed header,
|
||||||
|
scrolling body, inset shape and safe-area clearance. Reuse shared facts and loading
|
||||||
|
state. Keep the compact visible glyph, but ensure its touch hit area does not
|
||||||
|
intercept adjacent task controls. The drawer owns vertical scrolling, uses dynamic
|
||||||
|
viewport bounds and restores focus; no document horizontal overflow is allowed.
|
||||||
|
|
||||||
|
## Failure and recovery
|
||||||
|
|
||||||
|
Retain old facts during refresh, publish sanitized read failures, and recover on
|
||||||
|
the next scheduled or explicit read. Unknown status must not imply ready. Existing
|
||||||
|
credential authorization and sanitization are unchanged. Do not log provider
|
||||||
|
credentials or raw errors. No new telemetry or persistence is needed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Use fake-clock resource tests for scheduling, duplicate consumers, hidden/visible
|
||||||
|
transitions, failure recovery, transport absence, unmount and late settlement.
|
||||||
|
Use actual primitive integration tests for DOM trigger linkage and focus/Escape.
|
||||||
|
Desktop and mobile Kubernetes E2E prove no-hover updates and real visible details.
|
||||||
@@ -93,23 +93,9 @@ first-class Pod with an administrator-reviewed workload template.
|
|||||||
current credential view. Rotating or repairing kubeconfig credentials must not
|
current credential view. Rotating or repairing kubeconfig credentials must not
|
||||||
leave list/card chrome pinned to a launch-time `Unauthorized` result after the
|
leave list/card chrome pinned to a launch-time `Unauthorized` result after the
|
||||||
current executor configuration can inspect the recorded Pod successfully.
|
current executor configuration can inspect the recorded Pod successfully.
|
||||||
- Sidebar and Kanban executor indicators hydrate their exact task/session status
|
- Compact sidebar and Kanban indicator hydration and responsive disclosure are
|
||||||
as soon as a valid indicator is rendered. The user does not need to hover the
|
owned by [task executor status indicators](../executors/requirements/task-status-indicators.md)
|
||||||
icon before its healthy, failed, or unavailable tone becomes truthful. Exact
|
and their [system design](../executors/system-design/task-status-indicators.md).
|
||||||
duplicate scopes share one in-flight read and one recent result; a later
|
|
||||||
hover or keyboard focus refreshes that scope without clearing the last known
|
|
||||||
facts while the refresh is pending.
|
|
||||||
- A fine-pointer task-list or Kanban executor indicator uses the same compact,
|
|
||||||
structured disclosure language as the task Pull Request indicator: a
|
|
||||||
focusable icon trigger, a bounded summary surface, a clear identity header,
|
|
||||||
and aligned labelled status rows. It does not fall back to an unstructured
|
|
||||||
stack of sentences. Kubernetes rows include state, restart count, workspace
|
|
||||||
mode, creation time, last check, and a sanitized failure when present.
|
|
||||||
- On a coarse pointer, the same compact executor indicator is tappable and
|
|
||||||
opens a bottom Drawer containing the same status facts. Its touch target is
|
|
||||||
at least 44 px without making dense task rows visually taller, and the Drawer
|
|
||||||
owns bounded scrolling plus safe-area padding. Hover is never the only way to
|
|
||||||
discover the status.
|
|
||||||
- The raw PodTemplate YAML field grows and shrinks with its content from a compact
|
- The raw PodTemplate YAML field grows and shrinks with its content from a compact
|
||||||
minimum height. It has no independent vertical scrollbar; the settings page is
|
minimum height. It has no independent vertical scrollbar; the settings page is
|
||||||
the vertical scroll owner, while unwrapped YAML retains contained horizontal
|
the vertical scroll owner, while unwrapped YAML retains contained horizontal
|
||||||
@@ -354,27 +340,8 @@ identity check or inventory lookup stops cleanup without deleting anything.
|
|||||||
kubeconfig credentials can inspect the exact recorded Pod and the indicator
|
kubeconfig credentials can inspect the exact recorded Pod and the indicator
|
||||||
mounts or its disclosure opens again, **THEN** the status is re-read, the
|
mounts or its disclosure opens again, **THEN** the status is re-read, the
|
||||||
error clears, and the Pod icon uses the current healthy tone.
|
error clears, and the Pod icon uses the current healthy tone.
|
||||||
- **GIVEN** Kanban renders a Kubernetes card with an executor, task, and primary
|
- Compact indicator acceptance criteria are maintained in
|
||||||
session identity, **WHEN** the card enters the document, **THEN** its exact Pod
|
[REQ-EXECUTORS-TASK-STATUS-001](../executors/requirements/task-status-indicators.md).
|
||||||
status is requested before any hover and the Pod icon updates to the returned
|
|
||||||
semantic tone. Another mounted indicator for that exact scope joins the same
|
|
||||||
request instead of issuing a duplicate.
|
|
||||||
- **GIVEN** an eager status read is already in flight, **WHEN** the user hovers
|
|
||||||
or keyboard-focuses the executor indicator, **THEN** the disclosure opens with
|
|
||||||
visible loading feedback, retains any last known facts, and joins the causal
|
|
||||||
read. A later open after settlement performs a fresh exact read.
|
|
||||||
- **GIVEN** the rendered task has no primary session or lacks the executor
|
|
||||||
identity required by its status source, **WHEN** its indicator renders,
|
|
||||||
**THEN** no malformed request is sent and the icon plus disclosure report an
|
|
||||||
unavailable state rather than `ready`.
|
|
||||||
- **GIVEN** a fine-pointer user opens a Kubernetes executor indicator in Kanban
|
|
||||||
or the task sidebar, **WHEN** the summary appears, **THEN** it uses a bounded
|
|
||||||
Pull Request-style identity header and aligned labelled rows for state,
|
|
||||||
restarts, workspace, created time, and last check, with the failure reason as
|
|
||||||
a distinct sanitized status row when present.
|
|
||||||
- **GIVEN** the same indicator is rendered for a coarse pointer, **WHEN** the
|
|
||||||
user taps its 44 px target, **THEN** a safe-area-aware bottom Drawer presents
|
|
||||||
the same facts and can be dismissed without selecting or opening the task row.
|
|
||||||
- **GIVEN** the recorded Kubernetes Pod is pending, failed, missing, or cannot
|
- **GIVEN** the recorded Kubernetes Pod is pending, failed, missing, or cannot
|
||||||
be inspected, **WHEN** the task disclosure refreshes, **THEN** it shows the
|
be inspected, **WHEN** the task disclosure refreshes, **THEN** it shows the
|
||||||
sanitized live state or unavailable/error state and never reports `ready`
|
sanitized live state or unavailable/error state and never reports `ready`
|
||||||
|
|||||||
Reference in New Issue
Block a user