mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
Show invalid org chain agent warnings
This commit is contained in:
1 parent
948ffaf274
commit
fa1bc84b7e
10 files changed
+158
-14
No files matched your search
@@ -126,6 +126,8 @@ export function AgentActionButtons({
|
||||
runLabel = "Run now",
|
||||
showStatus = true,
|
||||
actionsDisabled = false,
|
||||
workActionsDisabled = false,
|
||||
workActionsDisabledReason,
|
||||
navigateToRunOnInvoke = true,
|
||||
onActionError,
|
||||
children,
|
||||
@@ -138,6 +140,8 @@ export function AgentActionButtons({
|
||||
runLabel?: string;
|
||||
showStatus?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
workActionsDisabled?: boolean;
|
||||
workActionsDisabledReason?: string;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/**
|
||||
* Optional inline error reporter. When provided it is used instead of a toast
|
||||
@@ -259,6 +263,8 @@ export function AgentActionButtons({
|
||||
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const disabled = actionsDisabled || agentAction.isPending;
|
||||
const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled;
|
||||
const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled);
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||
@@ -266,13 +272,15 @@ export function AgentActionButtons({
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
disabled={assignAndRunDisabled}
|
||||
title={workActionsDisabled ? workActionsDisabledReason : undefined}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
disabled={assignAndRunDisabled}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
@@ -280,7 +288,7 @@ export function AgentActionButtons({
|
||||
isPaused={isPaused}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
disabled={pauseResumeDisabled}
|
||||
size={size}
|
||||
/>
|
||||
{showStatus && (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { issuesApi } from "../api/issues";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap } from "../lib/company-members";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, isAgentTaskTarget } from "../lib/company-members";
|
||||
import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue-assignee-overrides";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import {
|
||||
@@ -545,7 +545,7 @@ export function IssueProperties({
|
||||
const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]);
|
||||
const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]);
|
||||
const sortedAgents = useMemo(
|
||||
() => sortAgentsByRecency((agents ?? []).filter((a) => a.status !== "terminated"), recentAssigneeIds),
|
||||
() => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds),
|
||||
[agents, recentAssigneeIds],
|
||||
);
|
||||
const recentAssigneeValues = useMemo(
|
||||
|
||||
@@ -13,7 +13,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { buildCompanyUserInlineOptions, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { orderReusableExecutionWorkspaces } from "../lib/reusable-execution-workspaces";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
@@ -1122,7 +1122,7 @@ export function NewIssueDialog() {
|
||||
...currentUserAssigneeOption(currentUserId),
|
||||
...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }),
|
||||
...sortAgentsByRecency(
|
||||
(agents ?? []).filter((agent) => agent.status !== "terminated"),
|
||||
(agents ?? []).filter(isAgentTaskTarget),
|
||||
recentAssigneeIds,
|
||||
).map((agent) => ({
|
||||
id: assigneeValueFromSelection({ assigneeAgentId: agent.id }),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
@@ -114,12 +115,15 @@ function SidebarAgentItem({
|
||||
const isActive = activeAgentId === routeRef;
|
||||
const isPaused = agent.status === "paused";
|
||||
const isBudgetPaused = isPaused && agent.pauseReason === "budget";
|
||||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||
const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent";
|
||||
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused;
|
||||
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused || (isPaused && hasInvalidOrgChain);
|
||||
const pauseResumeDisabledLabel = disabled
|
||||
? "Updating..."
|
||||
: isBudgetPaused
|
||||
? "Budget paused"
|
||||
: isPaused && hasInvalidOrgChain
|
||||
? "Invalid org chain"
|
||||
: pauseResumeLabel;
|
||||
|
||||
return (
|
||||
@@ -139,6 +143,9 @@ function SidebarAgentItem({
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : null}
|
||||
{(agent.pauseReason === "budget" || runCount > 0) && (
|
||||
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
{agent.pauseReason === "budget" ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildCompanyUserProfileMap,
|
||||
buildMarkdownMentionOptions,
|
||||
} from "./company-members";
|
||||
import type { AgentOrgChainHealth } from "@paperclipai/shared";
|
||||
|
||||
const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
||||
id: overrides.id ?? "member-1",
|
||||
@@ -22,6 +23,15 @@ const activeMember = (overrides: Partial<CompanyMember>): CompanyMember => ({
|
||||
grants: overrides.grants ?? [],
|
||||
});
|
||||
|
||||
const invalidOrgChainHealth: AgentOrgChainHealth = {
|
||||
status: "invalid_org_chain",
|
||||
reason: "terminated_ancestor",
|
||||
fullChain: [],
|
||||
firstInvalidAncestor: { id: "manager-1", name: "Manager", status: "terminated" },
|
||||
invalidAncestors: [{ id: "manager-1", name: "Manager", status: "terminated" }],
|
||||
repairGuidance: "Repair the reporting chain.",
|
||||
};
|
||||
|
||||
describe("company-members helpers", () => {
|
||||
it("builds labels from company member profiles", () => {
|
||||
const labels = buildCompanyUserLabelMap([
|
||||
@@ -82,6 +92,25 @@ describe("company-members helpers", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits invalid-org-chain agents from markdown mention options", () => {
|
||||
const options = buildMarkdownMentionOptions({
|
||||
agents: [
|
||||
{ id: "agent-1", name: "CodexCoder", status: "active", icon: "code" },
|
||||
{
|
||||
id: "agent-2",
|
||||
name: "InvalidCoder",
|
||||
status: "active",
|
||||
icon: "code",
|
||||
orgChainHealth: invalidOrgChainHealth,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(options).toEqual([
|
||||
{ id: "agent:agent-1", name: "CodexCoder", kind: "agent", agentId: "agent-1", agentIcon: "code" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts read-only directory entries for assignee and mention helpers", () => {
|
||||
const users: CompanyUserDirectoryEntry[] = [
|
||||
{
|
||||
|
||||
@@ -84,15 +84,23 @@ export function buildCompanyUserMentionOptions(
|
||||
}));
|
||||
}
|
||||
|
||||
export function isAgentTaskTarget(agent: Pick<Agent, "status" | "orgChainHealth">): boolean {
|
||||
return (
|
||||
agent.status !== "terminated" &&
|
||||
agent.status !== "pending_approval" &&
|
||||
agent.orgChainHealth?.status !== "invalid_org_chain"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMarkdownMentionOptions(args: {
|
||||
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon">> | null | undefined;
|
||||
agents?: Array<Pick<Agent, "id" | "name" | "status" | "icon" | "orgChainHealth">> | null | undefined;
|
||||
projects?: Array<Pick<Project, "id" | "name" | "color">> | null | undefined;
|
||||
members?: CompanyUserRecord[] | null | undefined;
|
||||
}): MentionOption[] {
|
||||
const options: MentionOption[] = [
|
||||
...buildCompanyUserMentionOptions(args.members),
|
||||
...[...(args.agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.filter(isAgentTaskTarget)
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.map((agent) => ({
|
||||
id: `agent:${agent.id}`,
|
||||
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
ArrowLeft,
|
||||
HelpCircle,
|
||||
FolderOpen,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -123,6 +124,12 @@ const SECRET_ENV_KEY_RE =
|
||||
const COMMAND_ENV_KEY_RE = /(^command$|^cmd$|command[-_]?line|resolved[-_]?command|PAPERCLIP_RESOLVED_COMMAND)/i;
|
||||
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
|
||||
|
||||
function formatOrgChainHealthPath(agent: AgentDetailRecord) {
|
||||
return agent.orgChainHealth?.fullChain
|
||||
.map((entry) => `${entry.name}${entry.status !== "active" && entry.status !== "idle" ? ` (${entry.status})` : ""}`)
|
||||
.join(" -> ") ?? agent.name;
|
||||
}
|
||||
|
||||
function redactPathText(value: string, censorUsernameInLogs: boolean) {
|
||||
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
|
||||
}
|
||||
@@ -913,6 +920,7 @@ export function AgentDetail() {
|
||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||
const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
|
||||
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
|
||||
const agentMembershipPending =
|
||||
@@ -956,6 +964,27 @@ export function AgentDetail() {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{hasInvalidOrgChain ? (
|
||||
<div className="flex items-start gap-3 border border-amber-300/35 bg-amber-300/10 px-3 py-2 text-sm text-amber-100">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="font-medium">Invalid reporting chain</p>
|
||||
<p className="text-amber-100/90">
|
||||
{agent.name} cannot accept tasks or start runs until its reporting chain is repaired.
|
||||
</p>
|
||||
<p className="break-words font-mono text-xs text-amber-100/80">
|
||||
{formatOrgChainHealthPath(agent)}
|
||||
</p>
|
||||
{agent.orgChainHealth?.repairGuidance ? (
|
||||
<p className="text-amber-100/85">{agent.orgChainHealth.repairGuidance}</p>
|
||||
) : (
|
||||
<p className="text-amber-100/85">
|
||||
Assign this agent to an active manager/root, or explicitly pause or terminate the affected agent/subtree.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
@@ -981,6 +1010,8 @@ export function AgentDetail() {
|
||||
assignLabel="Assign Task"
|
||||
runLabel="Run Heartbeat"
|
||||
actionsDisabled={agentAction.isPending}
|
||||
workActionsDisabled={hasInvalidOrgChain}
|
||||
workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs"
|
||||
onActionError={setActionError}
|
||||
>
|
||||
{mobileLiveRun && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Agent } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { Agents } from "./Agents";
|
||||
import type { AgentOrgChainHealth } from "@paperclipai/shared";
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -105,6 +106,34 @@ function makeAgent(overrides: Partial<Agent>): Agent {
|
||||
};
|
||||
}
|
||||
|
||||
const invalidOrgChainHealth: AgentOrgChainHealth = {
|
||||
status: "invalid_org_chain",
|
||||
reason: "terminated_ancestor",
|
||||
fullChain: [
|
||||
{
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Alpha",
|
||||
status: "active",
|
||||
reportsTo: "manager-1",
|
||||
depth: 0,
|
||||
relation: "self",
|
||||
},
|
||||
{
|
||||
id: "manager-1",
|
||||
companyId: "company-1",
|
||||
name: "Terminated Manager",
|
||||
status: "terminated",
|
||||
reportsTo: null,
|
||||
depth: 1,
|
||||
relation: "ancestor",
|
||||
},
|
||||
],
|
||||
firstInvalidAncestor: { id: "manager-1", name: "Terminated Manager", status: "terminated" },
|
||||
invalidAncestors: [{ id: "manager-1", name: "Terminated Manager", status: "terminated" }],
|
||||
repairGuidance: "Alpha reports through terminated ancestor Terminated Manager.",
|
||||
};
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -224,4 +253,26 @@ describe("Agents", () => {
|
||||
expect(titleCell?.textContent).toContain("Alpha");
|
||||
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps invalid-org-chain agents visible with a warning marker", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ orgChainHealth: invalidOrgChainHealth }),
|
||||
]);
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Alpha");
|
||||
expect(container.querySelector('[aria-label="Invalid reporting chain"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import { AlertTriangle, Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
@@ -211,6 +211,7 @@ export function Agents() {
|
||||
{effectiveView === "list" && filtered.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{filtered.map((agent) => {
|
||||
const hasInvalidOrgChain = agent.orgChainHealth?.status === "invalid_org_chain";
|
||||
return (
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
@@ -227,7 +228,11 @@ export function Agents() {
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
||||
)}
|
||||
leading={<AgentStatusCapsule status={agent.status} />}
|
||||
leading={hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : (
|
||||
<AgentStatusCapsule status={agent.status} />
|
||||
)}
|
||||
meta={
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns agent={agent} />
|
||||
@@ -366,6 +371,7 @@ function OrgTreeNode({
|
||||
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
|
||||
}) {
|
||||
const agent = agentMap.get(node.id);
|
||||
const hasInvalidOrgChain = Boolean(agent && agent.orgChainHealth?.status === "invalid_org_chain");
|
||||
const membershipState = resourceMembershipState(memberships, "agent", node.id);
|
||||
const pending = membershipMutation.isPending &&
|
||||
membershipMutation.variables?.resourceType === "agent" &&
|
||||
@@ -381,7 +387,11 @@ function OrgTreeNode({
|
||||
membershipState === "left" && "text-foreground/55",
|
||||
)}
|
||||
>
|
||||
<AgentStatusCapsule status={node.status} />
|
||||
{hasInvalidOrgChain ? (
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-amber-500" aria-label="Invalid reporting chain" />
|
||||
) : (
|
||||
<AgentStatusCapsule status={node.status} />
|
||||
)}
|
||||
<div className="flex-1 min-w-[7rem]">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useSidebar } from "../context/SidebarContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { buildCompanyUserInlineOptions, buildCompanyUserLabelMap, buildCompanyUserProfileMap, buildMarkdownMentionOptions, isAgentTaskTarget } from "../lib/company-members";
|
||||
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { keepPreviousDataForSameQueryTail } from "../lib/query-placeholder-data";
|
||||
@@ -1603,7 +1603,7 @@ export function IssueDetail() {
|
||||
const options: Array<{ id: string; label: string; searchText?: string }> = [];
|
||||
options.push(...buildCompanyUserInlineOptions(companyMembers?.users, { excludeUserIds: [currentUserId] }));
|
||||
const activeAgents = [...(agents ?? [])]
|
||||
.filter((agent) => agent.status !== "terminated")
|
||||
.filter(isAgentTaskTarget)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const agent of activeAgents) {
|
||||
options.push({ id: `agent:${agent.id}`, label: agent.name });
|
||||
|
||||
Reference in new issue
Block a user