Merge pull request #2781 from Molecule-AI/feat/canvas-show-misconfigured-state
feat(canvas): render misconfigured workspaces with configuration_status from agent_card
This commit is contained in:
commit
3f2cc8cdd6
@ -3,6 +3,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
@ -35,8 +36,28 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
}
|
||||
|
||||
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
// Configuration-status overlay (PR #2756 / #467 chain). When the
|
||||
// workspace is reachable but adapter.setup() failed (typically a
|
||||
// missing/rotated LLM credential), the agent_card carries
|
||||
// configuration_status: "not_configured". Surface this as a distinct
|
||||
// tile state so the operator sees a useful error instead of an
|
||||
// ambiguous "online but silent" workspace.
|
||||
//
|
||||
// The override only applies when the underlying status is "online" —
|
||||
// a workspace that's actually offline / failed / provisioning gets
|
||||
// its own treatment. "online + not_configured" is the gap PR #2756
|
||||
// introduced; everything else was already covered.
|
||||
const isMisconfigured =
|
||||
data.status === "online" &&
|
||||
getConfigurationStatus(data.agentCard) === "not_configured";
|
||||
const configurationError = getConfigurationError(data.agentCard);
|
||||
const effectiveStatus = isMisconfigured ? "not_configured" : data.status;
|
||||
const statusCfg = STATUS_CONFIG[effectiveStatus] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
const tooltipExtra = isMisconfigured && configurationError
|
||||
? `Agent not configured: ${configurationError}`
|
||||
: null;
|
||||
void tooltipExtra; // wired in via aria-label below; reserved here for future tooltip surface.
|
||||
// Org-deploy context — four derived flags off one store subscription.
|
||||
// Drives the shimmer while provisioning, the dimmed/non-draggable
|
||||
// treatment on locked descendants, and the Cancel pill on the root.
|
||||
@ -75,7 +96,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${data.name} workspace — ${data.status}`}
|
||||
aria-label={
|
||||
isMisconfigured && configurationError
|
||||
? `${data.name} workspace — agent not configured: ${configurationError}`
|
||||
: `${data.name} workspace — ${data.status}`
|
||||
}
|
||||
title={isMisconfigured && configurationError ? `Agent not configured: ${configurationError}` : undefined}
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@ -283,11 +309,12 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
{/* Bottom row: status / active tasks */}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{data.status !== "online" ? (
|
||||
{effectiveStatus !== "online" ? (
|
||||
<div className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
effectiveStatus === "failed" ? "text-bad" :
|
||||
effectiveStatus === "degraded" ? "text-warm" :
|
||||
effectiveStatus === "not_configured" ? "text-warm" :
|
||||
effectiveStatus === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
@ -313,6 +340,19 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{data.lastSampleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration error preview — same visual as the degraded
|
||||
* error preview but keyed off the agent_card's configuration_status.
|
||||
* Tells the operator which env var is missing so they can fix it
|
||||
* without having to dig into the workspace logs. */}
|
||||
{isMisconfigured && configurationError && (
|
||||
<div
|
||||
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
|
||||
title={configurationError}
|
||||
>
|
||||
{configurationError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
|
||||
@ -5,6 +5,13 @@ export const STATUS_CONFIG: Record<string, { dot: string; glow: string; label: s
|
||||
degraded: { dot: "bg-amber-400", glow: "shadow-amber-400/50", label: "Degraded", bar: "from-amber-500/20 to-transparent" },
|
||||
failed: { dot: "bg-red-400", glow: "shadow-red-400/50", label: "Failed", bar: "from-red-500/20 to-transparent" },
|
||||
provisioning: { dot: "bg-sky-400 motion-safe:animate-pulse", glow: "shadow-sky-400/50", label: "Starting", bar: "from-sky-500/20 to-transparent" },
|
||||
// not_configured: derived state from agent_card.configuration_status (PR #2756 chain).
|
||||
// Workspace is reachable (heartbeating, /agent-card serves) but adapter.setup()
|
||||
// failed — typically a missing/rotated LLM credential. Amber to differentiate from
|
||||
// online (green) and failed (red) — the workspace itself is healthy, just needs
|
||||
// configuration. Hover renders agent_card.configuration_error in the tooltip so
|
||||
// the operator sees the exact env var to set.
|
||||
not_configured: { dot: "bg-amber-300", glow: "shadow-amber-300/50", label: "Not configured", bar: "from-amber-400/20 to-transparent" },
|
||||
};
|
||||
|
||||
export function statusDotClass(status: string): string {
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getConfigurationStatus,
|
||||
getConfigurationError,
|
||||
} from "../canvas-topology";
|
||||
|
||||
// Tests for the getConfigurationStatus / getConfigurationError helpers
|
||||
// (issue #467 / PR #2756 chain). Surfacing the workspace's
|
||||
// `agent_card.configuration_status` is the user-visible payoff of
|
||||
// PR #2756's decoupling — without it, a misconfigured workspace looks
|
||||
// identical to a healthy one in the canvas tile.
|
||||
|
||||
describe("getConfigurationStatus", () => {
|
||||
it("returns null when agentCard is null", () => {
|
||||
expect(getConfigurationStatus(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when agentCard has no configuration_status", () => {
|
||||
expect(getConfigurationStatus({ name: "x" })).toBe(null);
|
||||
});
|
||||
|
||||
it("returns 'ready' when agent reports configuration ok", () => {
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "ready" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'not_configured' when agent reports setup failed", () => {
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "not_configured" }),
|
||||
).toBe("not_configured");
|
||||
});
|
||||
|
||||
it("ignores unknown values defensively", () => {
|
||||
// A future agent reporting a status string we don't yet recognise
|
||||
// shouldn't crash the canvas — we treat it as 'no info' (null).
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: "starting" }),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: 42 }),
|
||||
).toBe(null);
|
||||
expect(
|
||||
getConfigurationStatus({ configuration_status: null }),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfigurationError", () => {
|
||||
it("returns null when agentCard is null", () => {
|
||||
expect(getConfigurationError(null)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when status is 'ready' even if error string present", () => {
|
||||
// Defensive: if the agent somehow ships configuration_status=ready
|
||||
// alongside a stale configuration_error from a previous boot, we
|
||||
// trust the live status flag and don't surface the stale error.
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "ready",
|
||||
configuration_error: "stale: was unset",
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns the error string when status is 'not_configured'", () => {
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error:
|
||||
"RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
||||
}),
|
||||
).toBe(
|
||||
"RuntimeError: Neither OPENAI_API_KEY nor MINIMAX_API_KEY is set",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when status is 'not_configured' but error is missing", () => {
|
||||
expect(
|
||||
getConfigurationError({ configuration_status: "not_configured" }),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when error is empty string", () => {
|
||||
// Empty string isn't actionable for the operator — treat same as
|
||||
// missing.
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error: "",
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null when error is non-string", () => {
|
||||
expect(
|
||||
getConfigurationError({
|
||||
configuration_status: "not_configured",
|
||||
configuration_error: { reason: "object" },
|
||||
}),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
@ -564,3 +564,42 @@ export function extractSkillNames(agentCard: Record<string, unknown> | null): st
|
||||
.map((skill: Record<string, unknown>) => String(skill.name || skill.id || ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration status reported by the workspace, or null
|
||||
* when the agent card doesn't carry one (older runtime, or pre-PR #2756
|
||||
* worker).
|
||||
*
|
||||
* Pairs with molecule-core PR #2756: when adapter.setup() fails, the
|
||||
* runtime mounts a not-configured handler AND advertises the failure
|
||||
* via agent_card.configuration_status = "not_configured" +
|
||||
* configuration_error = "<reason>". Canvas reads both to render a
|
||||
* "needs config" tile instead of a confused "online but silent" state.
|
||||
*
|
||||
* Returns null (not undefined) so callers can distinguish "no info"
|
||||
* from explicit values via a strict equality check.
|
||||
*/
|
||||
export function getConfigurationStatus(
|
||||
agentCard: Record<string, unknown> | null,
|
||||
): "ready" | "not_configured" | null {
|
||||
if (!agentCard) return null;
|
||||
const raw = agentCard.configuration_status;
|
||||
if (raw === "ready" || raw === "not_configured") return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configuration error string from the agent card when
|
||||
* configuration_status is "not_configured", or null otherwise.
|
||||
*
|
||||
* Already redacted server-side via secret_redactor (PR #2778) — safe to
|
||||
* render in the UI verbatim.
|
||||
*/
|
||||
export function getConfigurationError(
|
||||
agentCard: Record<string, unknown> | null,
|
||||
): string | null {
|
||||
if (!agentCard) return null;
|
||||
if (getConfigurationStatus(agentCard) !== "not_configured") return null;
|
||||
const raw = agentCard.configuration_error;
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user