diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 67cf33f0..1a222f27 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -99,6 +99,7 @@ export type { AgentRuntimeState, AgentTaskSession, AgentWakeupRequest, + InstanceSchedulerHeartbeatAgent, LiveEvent, DashboardSummary, ActivityEvent, diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 7a1290e0..2e5a2006 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -1,4 +1,6 @@ import type { + AgentRole, + AgentStatus, HeartbeatInvocationSource, HeartbeatRunStatus, WakeupTriggerDetail, @@ -105,3 +107,20 @@ export interface AgentWakeupRequest { createdAt: Date; updatedAt: Date; } + +export interface InstanceSchedulerHeartbeatAgent { + id: string; + companyId: string; + companyName: string; + companyIssuePrefix: string; + agentName: string; + agentUrlKey: string; + role: AgentRole; + title: string | null; + status: AgentStatus; + adapterType: string; + intervalSec: number; + heartbeatEnabled: boolean; + schedulerActive: boolean; + lastHeartbeatAt: Date | null; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index c01072d9..07862c58 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -48,6 +48,7 @@ export type { AgentRuntimeState, AgentTaskSession, AgentWakeupRequest, + InstanceSchedulerHeartbeatAgent, } from "./heartbeat.js"; export type { LiveEvent } from "./live.js"; export type { DashboardSummary } from "./dashboard.js"; diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c4485d30..b1b53759 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -8,9 +8,11 @@ import { createAgentKeySchema, createAgentHireSchema, createAgentSchema, + deriveAgentUrlKey, isUuidLike, resetAgentSessionSchema, testAdapterEnvironmentSchema, + type InstanceSchedulerHeartbeatAgent, updateAgentPermissionsSchema, updateAgentInstructionsPathSchema, wakeAgentSchema, @@ -202,6 +204,21 @@ export function agentRoutes(db: Db) { return null; } + function parseNumberLike(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value !== "string") return null; + const parsed = Number(value.trim()); + return Number.isFinite(parsed) ? parsed : null; + } + + function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) { + const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {}; + return { + enabled: parseBooleanLike(heartbeat.enabled) ?? true, + intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0), + }; + } + function generateEd25519PrivateKeyPem(): string { const { privateKey } = generateKeyPairSync("ed25519"); return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); @@ -454,6 +471,81 @@ export function agentRoutes(db: Db) { res.json(result.map((agent) => redactForRestrictedAgentView(agent))); }); + router.get("/instance/scheduler-heartbeats", async (req, res) => { + assertBoard(req); + + const accessConditions = []; + if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { + const allowedCompanyIds = req.actor.companyIds ?? []; + if (allowedCompanyIds.length === 0) { + res.json([]); + return; + } + accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds)); + } + + const rows = await db + .select({ + id: agentsTable.id, + companyId: agentsTable.companyId, + agentName: agentsTable.name, + role: agentsTable.role, + title: agentsTable.title, + status: agentsTable.status, + adapterType: agentsTable.adapterType, + runtimeConfig: agentsTable.runtimeConfig, + lastHeartbeatAt: agentsTable.lastHeartbeatAt, + companyName: companies.name, + companyIssuePrefix: companies.issuePrefix, + }) + .from(agentsTable) + .innerJoin(companies, eq(agentsTable.companyId, companies.id)) + .where(accessConditions.length > 0 ? and(...accessConditions) : undefined) + .orderBy(companies.name, agentsTable.name); + + const items: InstanceSchedulerHeartbeatAgent[] = rows + .map((row) => { + const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig); + const statusEligible = + row.status !== "paused" && + row.status !== "terminated" && + row.status !== "pending_approval"; + + return { + id: row.id, + companyId: row.companyId, + companyName: row.companyName, + companyIssuePrefix: row.companyIssuePrefix, + agentName: row.agentName, + agentUrlKey: deriveAgentUrlKey(row.agentName, row.id), + role: row.role as InstanceSchedulerHeartbeatAgent["role"], + title: row.title, + status: row.status as InstanceSchedulerHeartbeatAgent["status"], + adapterType: row.adapterType, + intervalSec: policy.intervalSec, + heartbeatEnabled: policy.enabled, + schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0, + lastHeartbeatAt: row.lastHeartbeatAt, + }; + }) + .filter((item) => + item.intervalSec > 0 && + item.status !== "paused" && + item.status !== "terminated" && + item.status !== "pending_approval", + ) + .sort((left, right) => { + if (left.schedulerActive !== right.schedulerActive) { + return left.schedulerActive ? -1 : 1; + } + const companyOrder = left.companyName.localeCompare(right.companyName); + if (companyOrder !== 0) return companyOrder; + return left.agentName.localeCompare(right.agentName); + }); + + res.json(items); + }); + router.get("/companies/:companyId/org", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index a3e35de1..ed6c9c51 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity"; import { Inbox } from "./pages/Inbox"; import { CompanySettings } from "./pages/CompanySettings"; import { DesignGuide } from "./pages/DesignGuide"; +import { InstanceSettings } from "./pages/InstanceSettings"; import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab"; import { OrgChart } from "./pages/OrgChart"; import { NewAgent } from "./pages/NewAgent"; @@ -109,6 +110,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -156,6 +159,11 @@ function InboxRootRedirect() { return ; } +function LegacySettingsRedirect() { + const location = useLocation(); + return ; +} + function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); const { onboardingOpen } = useDialog(); @@ -234,9 +242,15 @@ export function App() { }> } /> + } /> + }> + } /> + } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index b579a65d..9b8a7145 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,4 +1,8 @@ -import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclipai/shared"; +import type { + HeartbeatRun, + HeartbeatRunEvent, + InstanceSchedulerHeartbeatAgent, +} from "@paperclipai/shared"; import { api } from "./client"; export interface ActiveRunForIssue extends HeartbeatRun { @@ -45,4 +49,6 @@ export const heartbeatsApi = { api.get(`/issues/${issueId}/active-run`), liveRunsForCompany: (companyId: string, minCount?: number) => api.get(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`), + listInstanceSchedulerAgents: () => + api.get("/instance/scheduler-heartbeats"), }; diff --git a/ui/src/components/CompanyRail.tsx b/ui/src/components/CompanyRail.tsx index 4737d047..fa981d1b 100644 --- a/ui/src/components/CompanyRail.tsx +++ b/ui/src/components/CompanyRail.tsx @@ -22,6 +22,7 @@ import { cn } from "../lib/utils"; import { queryKeys } from "../lib/queryKeys"; import { sidebarBadgesApi } from "../api/sidebarBadges"; import { heartbeatsApi } from "../api/heartbeats"; +import { useLocation, useNavigate } from "@/lib/router"; import { Tooltip, TooltipContent, @@ -154,6 +155,10 @@ function SortableCompanyItem({ export function CompanyRail() { const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); const { openOnboarding } = useDialog(); + const navigate = useNavigate(); + const location = useLocation(); + const isInstanceRoute = location.pathname.startsWith("/instance/"); + const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId; const sidebarCompanies = useMemo( () => companies.filter((company) => company.status !== "archived"), [companies], @@ -282,10 +287,15 @@ export function CompanyRail() { setSelectedCompanyId(company.id)} + onSelect={() => { + setSelectedCompanyId(company.id); + if (isInstanceRoute) { + navigate(`/${company.issuePrefix}/dashboard`); + } + }} /> ))} diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx new file mode 100644 index 00000000..ac933aa1 --- /dev/null +++ b/ui/src/components/InstanceSidebar.tsx @@ -0,0 +1,21 @@ +import { Clock3, Settings } from "lucide-react"; +import { SidebarNavItem } from "./SidebarNavItem"; + +export function InstanceSidebar() { + return ( + + ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index e12e6717..12cc6f88 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { BookOpen, Moon, Sun } from "lucide-react"; -import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { BookOpen, Moon, Settings, Sun } from "lucide-react"; +import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; +import { InstanceSidebar } from "./InstanceSidebar"; import { SidebarNavItem } from "./SidebarNavItem"; import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; @@ -42,6 +43,7 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); @@ -242,7 +244,7 @@ export function Layout() { >
- + {isInstanceSettingsRoute ? : }
@@ -252,6 +254,18 @@ export function Layout() { icon={BookOpen} className="flex-1 min-w-0" /> +
@@ -287,6 +301,18 @@ export function Layout() { icon={BookOpen} className="flex-1 min-w-0" /> + + +
+ ); + })} + + + + ))} + + )} + + ); +}