agent-relay-server 0.87.0 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/openapi.json +39 -1
- package/package.json +1 -1
- package/public/assets/{MessageBubble-DS4Hbn0k.js → MessageBubble-bI9fFi55.js} +3 -3
- package/public/assets/{MessageBubble-DS4Hbn0k.js.map → MessageBubble-bI9fFi55.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BYlragEi.js → PlanGraphPanel-a3Z57nIZ.js} +2 -2
- package/public/assets/{PlanGraphPanel-BYlragEi.js.map → PlanGraphPanel-a3Z57nIZ.js.map} +1 -1
- package/public/assets/{activity-CcGFS_sY.js → activity-VTlBVIox.js} +2 -2
- package/public/assets/{activity-CcGFS_sY.js.map → activity-VTlBVIox.js.map} +1 -1
- package/public/assets/{agents-BTh8SQiP.js → agents-C2TNRCn1.js} +2 -2
- package/public/assets/{agents-BTh8SQiP.js.map → agents-C2TNRCn1.js.map} +1 -1
- package/public/assets/{automation-izYGpZyQ.js → automation-CUSyRZla.js} +2 -2
- package/public/assets/{automation-izYGpZyQ.js.map → automation-CUSyRZla.js.map} +1 -1
- package/public/assets/{chat-ThyPRUPq.js → chat-BEssCn_H.js} +2 -2
- package/public/assets/{chat-ThyPRUPq.js.map → chat-BEssCn_H.js.map} +1 -1
- package/public/assets/{formatted-body-impl-DEYxGoiw.js → formatted-body-impl-DBbmythT.js} +2 -2
- package/public/assets/{formatted-body-impl-DEYxGoiw.js.map → formatted-body-impl-DBbmythT.js.map} +1 -1
- package/public/assets/{index-Bd1NBLyd.js → index-CiBdEE9x.js} +6 -6
- package/public/assets/{index-Bd1NBLyd.js.map → index-CiBdEE9x.js.map} +1 -1
- package/public/assets/index-F-FSPv4X.css +2 -0
- package/public/assets/{insights-DAVKQ_SF.js → insights-9P_MGbMS.js} +2 -2
- package/public/assets/{insights-DAVKQ_SF.js.map → insights-9P_MGbMS.js.map} +1 -1
- package/public/assets/{maintenance-BL4R9076.js → maintenance-CA1XujU3.js} +2 -2
- package/public/assets/{maintenance-BL4R9076.js.map → maintenance-CA1XujU3.js.map} +1 -1
- package/public/assets/managed-agents-DCfSlqW7.js +4 -0
- package/public/assets/managed-agents-DCfSlqW7.js.map +1 -0
- package/public/assets/{markdown-preview-impl-DK0wNWXJ.js → markdown-preview-impl-MgZ3Nr7K.js} +2 -2
- package/public/assets/{markdown-preview-impl-DK0wNWXJ.js.map → markdown-preview-impl-MgZ3Nr7K.js.map} +1 -1
- package/public/assets/{memory-BJEkDkwl.js → memory-DatJLjrc.js} +2 -2
- package/public/assets/{memory-BJEkDkwl.js.map → memory-DatJLjrc.js.map} +1 -1
- package/public/assets/{messages-DBn9tXx2.js → messages-CQsmbtzx.js} +2 -2
- package/public/assets/{messages-DBn9tXx2.js.map → messages-CQsmbtzx.js.map} +1 -1
- package/public/assets/{orchestrators-Bc1eC16Q.js → orchestrators-CAWYWXmh.js} +2 -2
- package/public/assets/{orchestrators-Bc1eC16Q.js.map → orchestrators-CAWYWXmh.js.map} +1 -1
- package/public/assets/overview-EqnqXVUb.js +2 -0
- package/public/assets/{overview-Bw7zrSqR.js.map → overview-EqnqXVUb.js.map} +1 -1
- package/public/assets/{pairs-BSwWnLK2.js → pairs-BfKenirO.js} +2 -2
- package/public/assets/{pairs-BSwWnLK2.js.map → pairs-BfKenirO.js.map} +1 -1
- package/public/assets/{security-wTBapq4K.js → security-CJVJ9MgS.js} +2 -2
- package/public/assets/{security-wTBapq4K.js.map → security-CJVJ9MgS.js.map} +1 -1
- package/public/assets/{select-D6HFOeLW.js → select-BcmIzk-E.js} +2 -2
- package/public/assets/{select-D6HFOeLW.js.map → select-BcmIzk-E.js.map} +1 -1
- package/public/assets/{settings-CDzp7vqJ.js → settings-BWO-UyHv.js} +2 -2
- package/public/assets/{settings-CDzp7vqJ.js.map → settings-BWO-UyHv.js.map} +1 -1
- package/public/assets/{tasks-Bd7Wjy6e.js → tasks-DTdI_MJi.js} +2 -2
- package/public/assets/{tasks-Bd7Wjy6e.js.map → tasks-DTdI_MJi.js.map} +1 -1
- package/public/assets/{teams-l3GUr2XV.js → teams-C9C3KVaV.js} +2 -2
- package/public/assets/{teams-l3GUr2XV.js.map → teams-C9C3KVaV.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-BQMMWctd.js → terminal-viewer-impl-BDBa94-B.js} +2 -2
- package/public/assets/{terminal-viewer-impl-BQMMWctd.js.map → terminal-viewer-impl-BDBa94-B.js.map} +1 -1
- package/public/assets/{user-avatar-DcA69UJK.js → user-avatar-BL-CJwnn.js} +2 -2
- package/public/assets/{user-avatar-DcA69UJK.js.map → user-avatar-BL-CJwnn.js.map} +1 -1
- package/public/assets/{work-queue-BYlFeJ1g.js → work-queue-Vc_2-4DG.js} +2 -2
- package/public/assets/{work-queue-BYlFeJ1g.js.map → work-queue-Vc_2-4DG.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/commands-db.ts +19 -2
- package/src/db/activity.ts +92 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp-ntfy-tools.ts +35 -0
- package/src/ntfy.ts +222 -0
- package/src/quota-advisory.ts +3 -2
- package/src/quota-threshold-config.ts +1 -1
- package/src/routes/index.ts +2 -1
- package/src/routes/spawn-policy.ts +19 -2
- package/public/assets/index-BjbvhfFt.css +0 -2
- package/public/assets/managed-agents-C7n1gl7E.js +0 -2
- package/public/assets/managed-agents-C7n1gl7E.js.map +0 -1
- package/public/assets/overview-Bw7zrSqR.js +0 -2
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{r as e}from"./chunk-CilyBKbf.js";import{Hn as t,Kn as n,Wn as r,rr as i}from"./lucide-react-CsPXvXMC.js";import{_ as a,lt as o}from"./display-CWMHll1J.js";import{
|
|
2
|
-
//# sourceMappingURL=user-avatar-
|
|
1
|
+
import{r as e}from"./chunk-CilyBKbf.js";import{Hn as t,Kn as n,Wn as r,rr as i}from"./lucide-react-CsPXvXMC.js";import{_ as a,lt as o}from"./display-CWMHll1J.js";import{w as s}from"./index-CiBdEE9x.js";var c=e(i(),1),l=r(),u={id:o};function d({user:e,address:r,distinct:i=!1,className:o}){let d=e?.avatarUrl??(e?.avatarArtifactId?`/artifacts/${encodeURIComponent(e.avatarArtifactId)}/content`:null),[f,p]=(0,c.useState)(null);(0,c.useEffect)(()=>{if(!d){p(null);return}let e=!1,t=null;return(async()=>{try{let r=await n(d);if(e)return;t=URL.createObjectURL(r),p(t)}catch{e||p(null)}})(),()=>{e=!0,t&&URL.revokeObjectURL(t)}},[d]);let m=e?.displayName||e?.handle||`User`;if(f)return(0,l.jsx)(`img`,{src:f,alt:m,title:m,className:t(`w-7 h-7 rounded-md object-cover`,o)});if(i){let e=(m.trim()[0]||`U`).toUpperCase();return(0,l.jsx)(`span`,{title:m,className:t(`inline-flex w-7 h-7 items-center justify-center rounded-md bg-muted text-xs font-semibold ring-1 ring-foreground/10`,a(r),o),children:e})}return(0,l.jsx)(s,{agent:u,className:o})}export{d as t};
|
|
2
|
+
//# sourceMappingURL=user-avatar-BL-CJwnn.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"user-avatar-
|
|
1
|
+
{"version":3,"file":"user-avatar-BL-CJwnn.js","names":[],"sources":["../../dashboard/src/components/shared/user-avatar.tsx"],"sourcesContent":["import { useEffect, useState } from 'react'\nimport { AgentTypeIcon } from '@/components/shared/agent-type-icon'\nimport { apiBlob } from '@/lib/api'\nimport { humanAccentClass } from '@/lib/display'\nimport { HUMAN_AGENT_ID } from '@/lib/constants'\nimport { cn } from '@/lib/utils'\nimport type { Agent, User } from '@/types'\n\n// Minimal stub so AgentTypeIcon resolves the generic 'user' face when no avatar exists.\nconst HUMAN_AGENT_STUB = { id: HUMAN_AGENT_ID } as unknown as Agent\n\n// A human's face (#390). Renders, in order of preference: the user's content-addressed avatar\n// image (the FK lands fully in #630 — until then `avatarArtifactId` is null and this path is\n// dormant), then a colored initial when we need to distinguish multiple operators, then the\n// generic operator icon. Degrades gracefully at every step so a single seeded user looks\n// exactly as before.\nexport function UserAvatar({\n user,\n address,\n distinct = false,\n className,\n}: {\n user: User | undefined\n /** The human address (`user` / `user:<id>`) — drives the deterministic accent color. */\n address?: string\n /** When true (multiple humans in view), fall back to a colored initial instead of the icon. */\n distinct?: boolean\n className?: string\n}) {\n const avatarUrl = user?.avatarUrl ?? (user?.avatarArtifactId ? `/artifacts/${encodeURIComponent(user.avatarArtifactId)}/content` : null)\n const [objectUrl, setObjectUrl] = useState<string | null>(null)\n\n useEffect(() => {\n if (!avatarUrl) { setObjectUrl(null); return }\n let cancelled = false\n let url: string | null = null\n void (async () => {\n try {\n const blob = await apiBlob(avatarUrl)\n if (cancelled) return\n url = URL.createObjectURL(blob)\n setObjectUrl(url)\n } catch {\n if (!cancelled) setObjectUrl(null)\n }\n })()\n return () => { cancelled = true; if (url) URL.revokeObjectURL(url) }\n }, [avatarUrl])\n\n const title = user?.displayName || user?.handle || 'User'\n\n if (objectUrl) {\n return <img src={objectUrl} alt={title} title={title} className={cn('w-7 h-7 rounded-md object-cover', className)} />\n }\n if (distinct) {\n const initial = (title.trim()[0] || 'U').toUpperCase()\n return (\n <span\n title={title}\n className={cn('inline-flex w-7 h-7 items-center justify-center rounded-md bg-muted text-xs font-semibold ring-1 ring-foreground/10', humanAccentClass(address), className)}\n >\n {initial}\n </span>\n )\n }\n return <AgentTypeIcon agent={HUMAN_AGENT_STUB} className={className} />\n}\n"],"mappings":"+NASM,EAAmB,CAAE,GAAI,EAAgB,CAO/C,SAAgB,EAAW,CACzB,OACA,UACA,WAAW,GACX,aAQC,CACD,IAAM,EAAY,GAAM,YAAc,GAAM,iBAAmB,cAAc,mBAAmB,EAAK,iBAAiB,CAAC,UAAY,MAC7H,CAAC,EAAW,IAAA,EAAA,EAAA,UAAwC,KAAK,EAE/D,EAAA,EAAA,eAAgB,CACd,GAAI,CAAC,EAAW,CAAE,EAAa,KAAK,CAAE,OACtC,IAAI,EAAY,GACZ,EAAqB,KAWzB,OAVM,SAAY,CAChB,GAAI,CACF,IAAM,EAAO,MAAM,EAAQ,EAAU,CACrC,GAAI,EAAW,OACf,EAAM,IAAI,gBAAgB,EAAK,CAC/B,EAAa,EAAI,MACX,CACD,GAAW,EAAa,KAAK,KAElC,KACS,CAAE,EAAY,GAAU,GAAK,IAAI,gBAAgB,EAAI,GACjE,CAAC,EAAU,CAAC,CAEf,IAAM,EAAQ,GAAM,aAAe,GAAM,QAAU,OAEnD,GAAI,EACF,OAAO,EAAA,EAAA,KAAC,MAAD,CAAK,IAAK,EAAW,IAAK,EAAc,QAAO,UAAW,EAAG,kCAAmC,EAAU,CAAI,CAAA,CAEvH,GAAI,EAAU,CACZ,IAAM,GAAW,EAAM,MAAM,CAAC,IAAM,KAAK,aAAa,CACtD,OACE,EAAA,EAAA,KAAC,OAAD,CACS,QACP,UAAW,EAAG,sHAAuH,EAAiB,EAAQ,CAAE,EAAU,UAEzK,EACI,CAAA,CAGX,OAAO,EAAA,EAAA,KAAC,EAAD,CAAe,MAAO,EAA6B,YAAa,CAAA"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{Wn as e,bn as t,cn as n,i as r,ut as i}from"./lucide-react-CsPXvXMC.js";import{l as a,t as o}from"./store-DsqUEZOf.js";import{K as s,d as c,mt as l}from"./display-CWMHll1J.js";import{t as u}from"./badge-CK6ejiOh.js";import{t as d}from"./button-BAwKuRIq.js";import{
|
|
2
|
-
//# sourceMappingURL=work-queue-
|
|
1
|
+
import{Wn as e,bn as t,cn as n,i as r,ut as i}from"./lucide-react-CsPXvXMC.js";import{l as a,t as o}from"./store-DsqUEZOf.js";import{K as s,d as c,mt as l}from"./display-CWMHll1J.js";import{t as u}from"./badge-CK6ejiOh.js";import{t as d}from"./button-BAwKuRIq.js";import{V as f,q as p,s as m}from"./index-CiBdEE9x.js";import{r as h,t as g}from"./card-DDxxo-Fo.js";var _=e(),v={open:`bg-blue-500/10 text-blue-400 border-blue-500/20`,blocked:`bg-yellow-500/10 text-yellow-400 border-yellow-500/20`,claimed:`bg-emerald-500/10 text-emerald-400 border-emerald-500/20`,in_progress:`bg-purple-500/10 text-purple-400 border-purple-500/20`};function y(){let e=a(),t=o(e=>e.agentsById),n=o(e=>e.compose),r=o(e=>e.set),s=o(e=>e.doClaimTask),l=o(e=>e.doClaim),d=o(e=>e.openTaskEvents),h=o(e=>e.showError),g=o(e=>e.openConfirm),v=o(e=>e.doDeleteMessage),y=o(e=>e.doUpdateTaskStatus),x=f(),S=p(),C=S.filter(e=>e.claimable).length;function w(e){if(!n.from){h(`Validation`,`Select a "Claim as" agent first.`);return}e.sourceType===`task`&&e.task?s(e.task.id):e.sourceType===`message`&&e.message&&l(e.message.id)}function T(e){g(`Cancel Item`,`Cancel "${e.title||(e.sourceType===`task`?`Task #${e.id}`:`Message #${e.id}`)}"? This cannot be undone.`,()=>{e.sourceType===`task`&&e.task?y(e.task,`canceled`):e.sourceType===`message`&&e.message&&v(e.message.id)})}function E(e){e.task&&d(e.task)}return(0,_.jsxs)(`div`,{className:`space-y-4`,children:[(0,_.jsxs)(`div`,{className:`flex items-center justify-between flex-wrap gap-2`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(i,{className:`w-5 h-5 text-muted-foreground`}),(0,_.jsx)(`h2`,{className:`text-lg font-semibold`,children:`Work Queue`}),C>0&&(0,_.jsxs)(u,{className:`bg-orange-500/20 text-orange-400 border-orange-500/30 border`,children:[C,` claimable`]})]}),(0,_.jsxs)(`div`,{className:`flex items-center gap-2`,children:[(0,_.jsx)(`label`,{className:`text-xs text-muted-foreground shrink-0`,children:`Claim as`}),(0,_.jsxs)(`select`,{className:`rounded-md border border-input bg-background px-3 py-1.5 text-sm`,value:n.from,onChange:e=>r({compose:{...n,from:e.target.value}}),children:[(0,_.jsx)(`option`,{value:``,children:`Select agent...`}),x.map(e=>(0,_.jsx)(`option`,{value:e.id,children:c(e)},e.id))]})]})]}),(0,_.jsx)(m,{className:`h-[calc(100dvh-11rem)]`,children:(0,_.jsxs)(`div`,{className:`space-y-2 pr-2`,children:[S.length===0&&(0,_.jsx)(`div`,{className:`text-center text-muted-foreground py-16 text-sm`,children:`Work queue is empty`}),S.map(n=>(0,_.jsx)(b,{item:n,now:e,agentName:e=>t[e]?c(t[e]):e.slice(-10),onClaim:()=>w(n),onCancel:()=>T(n),onEvents:n.task?()=>E(n):void 0},n.id))]})})]})}function b({item:e,now:i,agentName:a,onClaim:o,onCancel:c,onEvents:f}){let p=l[e.severity]||l.info,m=v[e.status]||`bg-zinc-500/10 text-zinc-400 border-zinc-500/20`;return(0,_.jsx)(g,{className:e.claimable?`ring-1 ring-orange-500/30`:``,children:(0,_.jsx)(h,{className:`p-3`,children:(0,_.jsxs)(`div`,{className:`flex items-start gap-3`,children:[(0,_.jsxs)(`div`,{className:`flex-1 min-w-0`,children:[(0,_.jsxs)(`div`,{className:`flex items-center gap-2 flex-wrap mb-1`,children:[(0,_.jsx)(`span`,{className:`font-medium text-sm truncate`,children:e.title}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${p}`,children:e.severity}),(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border ${m}`,children:e.status}),e.sourceType===`message`&&(0,_.jsx)(u,{variant:`outline`,className:`text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400`,children:`msg`})]}),e.body&&(0,_.jsx)(`p`,{className:`text-xs text-muted-foreground line-clamp-2 mb-2`,children:e.body}),(0,_.jsxs)(`div`,{className:`flex items-center gap-3 text-xs text-muted-foreground flex-wrap`,children:[e.owner&&(0,_.jsxs)(`span`,{title:`Owner`,children:[(0,_.jsx)(n,{className:`w-3 h-3 inline mr-1`}),a(e.owner)]}),e.source&&(0,_.jsx)(`span`,{className:`font-mono`,children:e.source}),(0,_.jsxs)(`span`,{title:String(e.updatedAt),children:[(0,_.jsx)(t,{className:`w-3 h-3 inline mr-1`}),s(i,e.updatedAt)]})]})]}),(0,_.jsxs)(`div`,{className:`flex flex-col gap-1 shrink-0`,children:[e.claimable&&(0,_.jsxs)(d,{size:`sm`,className:`h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white`,onClick:o,children:[(0,_.jsx)(n,{className:`w-3.5 h-3.5 mr-1`}),` Claim`]}),f&&(0,_.jsx)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs`,onClick:f,children:`Events`}),(0,_.jsxs)(d,{size:`sm`,variant:`outline`,className:`h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20`,onClick:c,children:[(0,_.jsx)(r,{className:`w-3.5 h-3.5 mr-1`}),` Cancel`]})]})]})})})}export{y as WorkQueueView};
|
|
2
|
+
//# sourceMappingURL=work-queue-Vc_2-4DG.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"work-queue-BYlFeJ1g.js","names":[],"sources":["../../dashboard/src/components/views/work-queue.tsx"],"sourcesContent":["import { useRelayStore, useNow } from '@/store'\nimport { useWorkQueueItems, useComposeAgents } from '@/hooks/use-selectors'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { ListChecks, CheckCircle2, Calendar, X } from 'lucide-react'\nimport { displayName, timeAgo } from '@/lib/display'\nimport { SEVERITY_COLORS } from '@/lib/constants'\nimport type { WorkQueueItem } from '@/types'\n\nconst STATUS_COLORS: Record<string, string> = {\n open: 'bg-blue-500/10 text-blue-400 border-blue-500/20',\n blocked: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',\n claimed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',\n in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',\n}\n\nexport function WorkQueueView() {\n const now = useNow()\n const agentsById = useRelayStore((s) => s.agentsById)\n const compose = useRelayStore((s) => s.compose)\n const set = useRelayStore((s) => s.set)\n const doClaimTask = useRelayStore((s) => s.doClaimTask)\n const doClaim = useRelayStore((s) => s.doClaim)\n const openTaskEvents = useRelayStore((s) => s.openTaskEvents)\n const showError = useRelayStore((s) => s.showError)\n const openConfirm = useRelayStore((s) => s.openConfirm)\n const doDeleteMessage = useRelayStore((s) => s.doDeleteMessage)\n const doUpdateTaskStatus = useRelayStore((s) => s.doUpdateTaskStatus)\n\n const composeAgents = useComposeAgents()\n const items = useWorkQueueItems()\n\n const claimableCount = items.filter((i) => i.claimable).length\n\n function handleClaim(item: WorkQueueItem) {\n if (!compose.from) { showError('Validation', 'Select a \"Claim as\" agent first.'); return }\n if (item.sourceType === 'task' && item.task) doClaimTask(item.task.id)\n else if (item.sourceType === 'message' && item.message) doClaim(item.message.id)\n }\n\n function handleCancel(item: WorkQueueItem) {\n const label = item.title || (item.sourceType === 'task' ? `Task #${item.id}` : `Message #${item.id}`)\n openConfirm('Cancel Item', `Cancel \"${label}\"? This cannot be undone.`, () => {\n if (item.sourceType === 'task' && item.task) doUpdateTaskStatus(item.task, 'canceled')\n else if (item.sourceType === 'message' && item.message) doDeleteMessage(item.message.id)\n })\n }\n\n function handleEvents(item: WorkQueueItem) {\n if (item.task) openTaskEvents(item.task)\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between flex-wrap gap-2\">\n <div className=\"flex items-center gap-2\">\n <ListChecks className=\"w-5 h-5 text-muted-foreground\" />\n <h2 className=\"text-lg font-semibold\">Work Queue</h2>\n {claimableCount > 0 && (\n <Badge className=\"bg-orange-500/20 text-orange-400 border-orange-500/30 border\">\n {claimableCount} claimable\n </Badge>\n )}\n </div>\n <div className=\"flex items-center gap-2\">\n <label className=\"text-xs text-muted-foreground shrink-0\">Claim as</label>\n <select\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm\"\n value={compose.from}\n onChange={(e) => set({ compose: { ...compose, from: e.target.value } })}\n >\n <option value=\"\">Select agent...</option>\n {composeAgents.map((a) => <option key={a.id} value={a.id}>{displayName(a)}</option>)}\n </select>\n </div>\n </div>\n\n <ScrollArea className=\"h-[calc(100dvh-11rem)]\">\n <div className=\"space-y-2 pr-2\">\n {items.length === 0 && (\n <div className=\"text-center text-muted-foreground py-16 text-sm\">Work queue is empty</div>\n )}\n {items.map((item) => (\n <WorkQueueCard\n key={item.id}\n item={item}\n now={now}\n agentName={(id) => agentsById[id] ? displayName(agentsById[id]) : id.slice(-10)}\n onClaim={() => handleClaim(item)}\n onCancel={() => handleCancel(item)}\n onEvents={item.task ? () => handleEvents(item) : undefined}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n )\n}\n\nfunction WorkQueueCard({\n item, now, agentName, onClaim, onCancel, onEvents,\n}: {\n item: WorkQueueItem\n now: number\n agentName: (id: string) => string\n onClaim: () => void\n onCancel: () => void\n onEvents?: () => void\n}) {\n const severityClass = SEVERITY_COLORS[item.severity] || SEVERITY_COLORS.info\n const statusClass = STATUS_COLORS[item.status] || 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'\n\n return (\n <Card className={item.claimable ? 'ring-1 ring-orange-500/30' : ''}>\n <CardContent className=\"p-3\">\n <div className=\"flex items-start gap-3\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap mb-1\">\n <span className=\"font-medium text-sm truncate\">{item.title}</span>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${severityClass}`}>{item.severity}</Badge>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${statusClass}`}>{item.status}</Badge>\n {item.sourceType === 'message' && (\n <Badge variant=\"outline\" className=\"text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400\">msg</Badge>\n )}\n </div>\n {item.body && (\n <p className=\"text-xs text-muted-foreground line-clamp-2 mb-2\">{item.body}</p>\n )}\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground flex-wrap\">\n {item.owner && (\n <span title=\"Owner\">\n <CheckCircle2 className=\"w-3 h-3 inline mr-1\" />{agentName(item.owner)}\n </span>\n )}\n {item.source && (\n <span className=\"font-mono\">{item.source}</span>\n )}\n <span title={String(item.updatedAt)}>\n <Calendar className=\"w-3 h-3 inline mr-1\" />{timeAgo(now, item.updatedAt)}\n </span>\n </div>\n </div>\n <div className=\"flex flex-col gap-1 shrink-0\">\n {item.claimable && (\n <Button size=\"sm\" className=\"h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white\" onClick={onClaim}>\n <CheckCircle2 className=\"w-3.5 h-3.5 mr-1\" /> Claim\n </Button>\n )}\n {onEvents && (\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs\" onClick={onEvents}>\n Events\n </Button>\n )}\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20\" onClick={onCancel}>\n <X className=\"w-3.5 h-3.5 mr-1\" /> Cancel\n </Button>\n </div>\n </div>\n </CardContent>\n </Card>\n )\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"work-queue-Vc_2-4DG.js","names":[],"sources":["../../dashboard/src/components/views/work-queue.tsx"],"sourcesContent":["import { useRelayStore, useNow } from '@/store'\nimport { useWorkQueueItems, useComposeAgents } from '@/hooks/use-selectors'\nimport { Card, CardContent } from '@/components/ui/card'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { ScrollArea } from '@/components/ui/scroll-area'\nimport { ListChecks, CheckCircle2, Calendar, X } from 'lucide-react'\nimport { displayName, timeAgo } from '@/lib/display'\nimport { SEVERITY_COLORS } from '@/lib/constants'\nimport type { WorkQueueItem } from '@/types'\n\nconst STATUS_COLORS: Record<string, string> = {\n open: 'bg-blue-500/10 text-blue-400 border-blue-500/20',\n blocked: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20',\n claimed: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',\n in_progress: 'bg-purple-500/10 text-purple-400 border-purple-500/20',\n}\n\nexport function WorkQueueView() {\n const now = useNow()\n const agentsById = useRelayStore((s) => s.agentsById)\n const compose = useRelayStore((s) => s.compose)\n const set = useRelayStore((s) => s.set)\n const doClaimTask = useRelayStore((s) => s.doClaimTask)\n const doClaim = useRelayStore((s) => s.doClaim)\n const openTaskEvents = useRelayStore((s) => s.openTaskEvents)\n const showError = useRelayStore((s) => s.showError)\n const openConfirm = useRelayStore((s) => s.openConfirm)\n const doDeleteMessage = useRelayStore((s) => s.doDeleteMessage)\n const doUpdateTaskStatus = useRelayStore((s) => s.doUpdateTaskStatus)\n\n const composeAgents = useComposeAgents()\n const items = useWorkQueueItems()\n\n const claimableCount = items.filter((i) => i.claimable).length\n\n function handleClaim(item: WorkQueueItem) {\n if (!compose.from) { showError('Validation', 'Select a \"Claim as\" agent first.'); return }\n if (item.sourceType === 'task' && item.task) doClaimTask(item.task.id)\n else if (item.sourceType === 'message' && item.message) doClaim(item.message.id)\n }\n\n function handleCancel(item: WorkQueueItem) {\n const label = item.title || (item.sourceType === 'task' ? `Task #${item.id}` : `Message #${item.id}`)\n openConfirm('Cancel Item', `Cancel \"${label}\"? This cannot be undone.`, () => {\n if (item.sourceType === 'task' && item.task) doUpdateTaskStatus(item.task, 'canceled')\n else if (item.sourceType === 'message' && item.message) doDeleteMessage(item.message.id)\n })\n }\n\n function handleEvents(item: WorkQueueItem) {\n if (item.task) openTaskEvents(item.task)\n }\n\n return (\n <div className=\"space-y-4\">\n <div className=\"flex items-center justify-between flex-wrap gap-2\">\n <div className=\"flex items-center gap-2\">\n <ListChecks className=\"w-5 h-5 text-muted-foreground\" />\n <h2 className=\"text-lg font-semibold\">Work Queue</h2>\n {claimableCount > 0 && (\n <Badge className=\"bg-orange-500/20 text-orange-400 border-orange-500/30 border\">\n {claimableCount} claimable\n </Badge>\n )}\n </div>\n <div className=\"flex items-center gap-2\">\n <label className=\"text-xs text-muted-foreground shrink-0\">Claim as</label>\n <select\n className=\"rounded-md border border-input bg-background px-3 py-1.5 text-sm\"\n value={compose.from}\n onChange={(e) => set({ compose: { ...compose, from: e.target.value } })}\n >\n <option value=\"\">Select agent...</option>\n {composeAgents.map((a) => <option key={a.id} value={a.id}>{displayName(a)}</option>)}\n </select>\n </div>\n </div>\n\n <ScrollArea className=\"h-[calc(100dvh-11rem)]\">\n <div className=\"space-y-2 pr-2\">\n {items.length === 0 && (\n <div className=\"text-center text-muted-foreground py-16 text-sm\">Work queue is empty</div>\n )}\n {items.map((item) => (\n <WorkQueueCard\n key={item.id}\n item={item}\n now={now}\n agentName={(id) => agentsById[id] ? displayName(agentsById[id]) : id.slice(-10)}\n onClaim={() => handleClaim(item)}\n onCancel={() => handleCancel(item)}\n onEvents={item.task ? () => handleEvents(item) : undefined}\n />\n ))}\n </div>\n </ScrollArea>\n </div>\n )\n}\n\nfunction WorkQueueCard({\n item, now, agentName, onClaim, onCancel, onEvents,\n}: {\n item: WorkQueueItem\n now: number\n agentName: (id: string) => string\n onClaim: () => void\n onCancel: () => void\n onEvents?: () => void\n}) {\n const severityClass = SEVERITY_COLORS[item.severity] || SEVERITY_COLORS.info\n const statusClass = STATUS_COLORS[item.status] || 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20'\n\n return (\n <Card className={item.claimable ? 'ring-1 ring-orange-500/30' : ''}>\n <CardContent className=\"p-3\">\n <div className=\"flex items-start gap-3\">\n <div className=\"flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 flex-wrap mb-1\">\n <span className=\"font-medium text-sm truncate\">{item.title}</span>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${severityClass}`}>{item.severity}</Badge>\n <Badge variant=\"outline\" className={`text-xs px-1.5 py-0 border ${statusClass}`}>{item.status}</Badge>\n {item.sourceType === 'message' && (\n <Badge variant=\"outline\" className=\"text-xs px-1.5 py-0 border border-zinc-500/20 text-zinc-400\">msg</Badge>\n )}\n </div>\n {item.body && (\n <p className=\"text-xs text-muted-foreground line-clamp-2 mb-2\">{item.body}</p>\n )}\n <div className=\"flex items-center gap-3 text-xs text-muted-foreground flex-wrap\">\n {item.owner && (\n <span title=\"Owner\">\n <CheckCircle2 className=\"w-3 h-3 inline mr-1\" />{agentName(item.owner)}\n </span>\n )}\n {item.source && (\n <span className=\"font-mono\">{item.source}</span>\n )}\n <span title={String(item.updatedAt)}>\n <Calendar className=\"w-3 h-3 inline mr-1\" />{timeAgo(now, item.updatedAt)}\n </span>\n </div>\n </div>\n <div className=\"flex flex-col gap-1 shrink-0\">\n {item.claimable && (\n <Button size=\"sm\" className=\"h-7 text-xs bg-orange-600 hover:bg-orange-700 text-white\" onClick={onClaim}>\n <CheckCircle2 className=\"w-3.5 h-3.5 mr-1\" /> Claim\n </Button>\n )}\n {onEvents && (\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs\" onClick={onEvents}>\n Events\n </Button>\n )}\n <Button size=\"sm\" variant=\"outline\" className=\"h-7 text-xs text-red-400 hover:text-red-300 hover:bg-red-500/10 border-red-500/20\" onClick={onCancel}>\n <X className=\"w-3.5 h-3.5 mr-1\" /> Cancel\n </Button>\n </div>\n </div>\n </CardContent>\n </Card>\n )\n}\n"],"mappings":"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"}
|
package/public/index.html
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2309090b'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
|
|
13
13
|
<link rel="manifest" href="manifest.webmanifest">
|
|
14
14
|
<link rel="apple-touch-icon" href="icons/agent-relay-192.png">
|
|
15
|
-
<script type="module" crossorigin src="./assets/index-
|
|
15
|
+
<script type="module" crossorigin src="./assets/index-CiBdEE9x.js"></script>
|
|
16
16
|
<link rel="modulepreload" crossorigin href="./assets/chunk-CilyBKbf.js">
|
|
17
17
|
<link rel="modulepreload" crossorigin href="./assets/preload-helper-CP_edrrL.js">
|
|
18
18
|
<link rel="modulepreload" crossorigin href="./assets/lucide-react-CsPXvXMC.js">
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<link rel="modulepreload" crossorigin href="./assets/badge-CK6ejiOh.js">
|
|
38
38
|
<link rel="modulepreload" crossorigin href="./assets/input-CLW2gDUM.js">
|
|
39
39
|
<link rel="modulepreload" crossorigin href="./assets/themes-mW9zZP4O.js">
|
|
40
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
40
|
+
<link rel="stylesheet" crossorigin href="./assets/index-F-FSPv4X.css">
|
|
41
41
|
</head>
|
|
42
42
|
<body class="bg-background text-foreground antialiased">
|
|
43
43
|
<div id="root"></div>
|
package/src/commands-db.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { getDb } from "./db";
|
|
2
|
+
import { getAgent, getDb, getTerminalEvent, recordTerminalEvent } from "./db";
|
|
3
|
+
import { stringValue } from "agent-relay-sdk";
|
|
3
4
|
import type { Command, CommandStatus, CreateCommandInput, UpdateCommandInput } from "./types";
|
|
4
5
|
|
|
5
6
|
interface CommandRow {
|
|
@@ -108,7 +109,9 @@ export function updateCommand(id: string, input: UpdateCommandInput): Command |
|
|
|
108
109
|
now,
|
|
109
110
|
id,
|
|
110
111
|
);
|
|
111
|
-
|
|
112
|
+
const updated = getCommand(id);
|
|
113
|
+
if (updated) recordShutdownTerminalEventOnSuccess(existing.status, updated);
|
|
114
|
+
return updated;
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
export function deleteCommand(id: string): boolean {
|
|
@@ -133,6 +136,20 @@ function defaultExpiresAt(type: string, now: number): number | undefined {
|
|
|
133
136
|
return undefined;
|
|
134
137
|
}
|
|
135
138
|
|
|
139
|
+
function recordShutdownTerminalEventOnSuccess(previousStatus: CommandStatus, command: Command): void {
|
|
140
|
+
if (command.type !== "agent.shutdown" || command.status !== "succeeded" || previousStatus === "succeeded") return;
|
|
141
|
+
const agentId = stringValue(command.result?.agentId) ?? stringValue(command.params?.agentId);
|
|
142
|
+
if (!agentId || getTerminalEvent(agentId)) return;
|
|
143
|
+
const agent = getAgent(agentId);
|
|
144
|
+
const hadReadySignal = !agent || agent.ready || typeof agent.meta?.readyNotifiedAt === "number";
|
|
145
|
+
recordTerminalEvent({
|
|
146
|
+
agentId,
|
|
147
|
+
reason: stringValue(command.result?.reason) ?? stringValue(command.params?.reason) ?? "shutdown",
|
|
148
|
+
kind: hadReadySignal ? "agent.exited" : "agent.spawn_failed",
|
|
149
|
+
createdAt: Date.now(),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
136
153
|
function rowToCommand(row: CommandRow): Command {
|
|
137
154
|
return {
|
|
138
155
|
id: row.id,
|
package/src/db/activity.ts
CHANGED
|
@@ -162,6 +162,34 @@ export interface AgentTimelineEntry {
|
|
|
162
162
|
metadata?: Record<string, unknown>;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
function commandTimelineEntries(where: string, params: any[], limit: number): AgentTimelineEntry[] {
|
|
166
|
+
const rows = getDb().query(`
|
|
167
|
+
SELECT id, type, status, result, error, correlation_id, created_at, updated_at
|
|
168
|
+
FROM commands
|
|
169
|
+
${where}
|
|
170
|
+
ORDER BY updated_at DESC, created_at DESC
|
|
171
|
+
LIMIT ?
|
|
172
|
+
`).all(...params, limit) as Array<{
|
|
173
|
+
id: string;
|
|
174
|
+
type: string;
|
|
175
|
+
status: string;
|
|
176
|
+
result: string | null;
|
|
177
|
+
error: string | null;
|
|
178
|
+
correlation_id: string | null;
|
|
179
|
+
created_at: number;
|
|
180
|
+
updated_at: number;
|
|
181
|
+
}>;
|
|
182
|
+
|
|
183
|
+
return rows.map((c) => ({
|
|
184
|
+
ts: c.updated_at ?? c.created_at,
|
|
185
|
+
source: "command" as const,
|
|
186
|
+
kind: c.type,
|
|
187
|
+
title: `${c.type} · ${c.status}`,
|
|
188
|
+
detail: c.error ?? (c.result ? JSON.stringify(parseJson(c.result, {})) : undefined),
|
|
189
|
+
metadata: { id: c.id, status: c.status, correlationId: c.correlation_id ?? undefined },
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
165
193
|
// One unified, newest-first stream of everything that happened to an agent —
|
|
166
194
|
// lifecycle transitions, liveness mismatches, commands + outcomes, and delivery
|
|
167
195
|
// attempts — joined by agentId and (for managed agents) policyName. Backs the
|
|
@@ -207,6 +235,70 @@ export function getAgentTimeline(agentId: string, opts: { limit?: number; since?
|
|
|
207
235
|
return entries.slice(0, limit);
|
|
208
236
|
}
|
|
209
237
|
|
|
238
|
+
// Policy-first variant for the managed-agents dashboard. This is deliberately
|
|
239
|
+
// read-only: it correlates the existing lifecycle audit rows, command outcomes,
|
|
240
|
+
// and delivery attempts even when a failed spawn never produced a live agent row.
|
|
241
|
+
export function getManagedPolicyTimeline(policyName: string, opts: { limit?: number; since?: number } = {}): AgentTimelineEntry[] {
|
|
242
|
+
const limit = opts.limit ?? 100;
|
|
243
|
+
const since = opts.since;
|
|
244
|
+
const entries: AgentTimelineEntry[] = [];
|
|
245
|
+
const state = getDb().query("SELECT agent_id, spawn_request_id FROM managed_agent_state WHERE policy_name = ?").get(policyName) as { agent_id?: string | null; spawn_request_id?: string | null } | undefined;
|
|
246
|
+
const agentId = state?.agent_id ?? undefined;
|
|
247
|
+
const spawnRequestId = state?.spawn_request_id ?? undefined;
|
|
248
|
+
|
|
249
|
+
const activityParams: any[] = [policyName];
|
|
250
|
+
const activitySince = since !== undefined ? "AND created_at >= ?" : "";
|
|
251
|
+
if (since !== undefined) activityParams.push(since);
|
|
252
|
+
const policyActivity = getDb().query(`
|
|
253
|
+
SELECT * FROM activity_events
|
|
254
|
+
WHERE json_extract(metadata, '$.policyName') = ?
|
|
255
|
+
${activitySince}
|
|
256
|
+
ORDER BY created_at DESC, id DESC
|
|
257
|
+
LIMIT ?
|
|
258
|
+
`).all(...activityParams, limit) as any[];
|
|
259
|
+
for (const ev of policyActivity.map(rowToActivityEvent)) {
|
|
260
|
+
entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (agentId) {
|
|
264
|
+
for (const ev of listActivityEvents({ agentId, limit, since })) {
|
|
265
|
+
entries.push({ ts: ev.createdAt, source: "activity", kind: ev.kind, title: ev.title, detail: ev.body, metadata: ev.metadata });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const attempts = (since !== undefined
|
|
269
|
+
? getDb().query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT ?").all(agentId, since, limit)
|
|
270
|
+
: getDb().query("SELECT * FROM message_delivery_attempts WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?").all(agentId, limit)) as any[];
|
|
271
|
+
for (const row of attempts) {
|
|
272
|
+
entries.push({ ts: row.created_at, source: "delivery", kind: `delivery.${row.action}`, title: `delivery ${row.status}`, detail: row.error ?? undefined, metadata: { messageId: row.message_id, status: row.status, poisonReason: row.poison_reason ?? undefined } });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const commandClauses = ["json_extract(params, '$.policyName') = ?"];
|
|
277
|
+
const commandParams: any[] = [policyName];
|
|
278
|
+
if (agentId) {
|
|
279
|
+
commandClauses.push("target = ?");
|
|
280
|
+
commandParams.push(agentId);
|
|
281
|
+
}
|
|
282
|
+
if (spawnRequestId) {
|
|
283
|
+
commandClauses.push("correlation_id = ?");
|
|
284
|
+
commandParams.push(spawnRequestId);
|
|
285
|
+
}
|
|
286
|
+
const commandSince = since !== undefined ? "AND updated_at >= ?" : "";
|
|
287
|
+
if (since !== undefined) commandParams.push(since);
|
|
288
|
+
entries.push(...commandTimelineEntries(`WHERE (${commandClauses.join(" OR ")}) ${commandSince}`, commandParams, limit));
|
|
289
|
+
|
|
290
|
+
const seen = new Set<string>();
|
|
291
|
+
const deduped = entries.filter((entry) => {
|
|
292
|
+
const commandId = typeof entry.metadata?.id === "string" ? entry.metadata.id : "";
|
|
293
|
+
const key = `${entry.source}:${commandId || `${entry.ts}:${entry.kind}:${entry.title}`}`;
|
|
294
|
+
if (seen.has(key)) return false;
|
|
295
|
+
seen.add(key);
|
|
296
|
+
return true;
|
|
297
|
+
});
|
|
298
|
+
deduped.sort((a, b) => b.ts - a.ts);
|
|
299
|
+
return deduped.slice(0, limit);
|
|
300
|
+
}
|
|
301
|
+
|
|
210
302
|
export function createActivityEvent(input: ActivityEventInput): ActivityEvent {
|
|
211
303
|
if (input.clientId) {
|
|
212
304
|
const existing = getDb().query("SELECT * FROM activity_events WHERE client_id = ?").get(input.clientId) as any | undefined;
|
package/src/mcp/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { TEAM_TOOLS, relayTeamTool } from "../mcp-team-tools";
|
|
|
9
9
|
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "../mcp-blackboard-tools";
|
|
10
10
|
import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
|
|
11
11
|
import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "../mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "../mcp-merge-tools";
|
|
12
|
+
import { NTFY_TOOLS, relayNotifyNtfy } from "../mcp-ntfy-tools";
|
|
12
13
|
import { AGENT_ACTION_TOOL, relayAgentAction } from "../mcp-agent-action"; import { providerCatalogToolInputSchema } from "../mcp-provider-catalog";
|
|
13
14
|
import type { ActivityKind } from "../types";
|
|
14
15
|
import { worktreeMcpInstructions } from "../workspace-phase";
|
|
@@ -33,6 +34,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
33
34
|
...BLACKBOARD_TOOLS,
|
|
34
35
|
...PROJECT_TOOLS,
|
|
35
36
|
...NOTIFICATION_SUBSCRIPTION_TOOLS,
|
|
37
|
+
...NTFY_TOOLS,
|
|
36
38
|
];
|
|
37
39
|
|
|
38
40
|
export async function postMcp(req: Request): Promise<Response> {
|
|
@@ -144,6 +146,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
144
146
|
else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
|
|
145
147
|
else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
|
|
146
148
|
else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
|
|
149
|
+
else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
|
|
147
150
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
148
151
|
|
|
149
152
|
auditToolCall(auth, name, "ok", result);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { MAX_BODY_BYTES } from "./config";
|
|
2
|
+
import { sendNtfyNotification, NTFY_PRIORITIES, type NtfyNotificationResult, type NtfyPriority } from "./ntfy";
|
|
3
|
+
import { optionalEnum } from "./validation";
|
|
4
|
+
import type { ToolDefinition } from "./mcp/types";
|
|
5
|
+
import { optionalString, optionalStringArray, stringField } from "./mcp/validation";
|
|
6
|
+
|
|
7
|
+
export const NTFY_TOOLS: ToolDefinition[] = [
|
|
8
|
+
{
|
|
9
|
+
name: "relay_notify_ntfy",
|
|
10
|
+
description: "Send an egress-only ntfy notification to the configured operator topic. Supports title, body, priority min→urgent, optional tags, and click-through link.",
|
|
11
|
+
requiredScopes: ["notifications:write"],
|
|
12
|
+
inputSchema: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
title: { type: "string" },
|
|
16
|
+
body: { type: "string" },
|
|
17
|
+
priority: { type: "string", enum: NTFY_PRIORITIES },
|
|
18
|
+
tags: { type: "array", items: { type: "string" } },
|
|
19
|
+
click: { type: "string" },
|
|
20
|
+
},
|
|
21
|
+
required: ["title", "body"],
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export async function relayNotifyNtfy(args: Record<string, unknown>): Promise<NtfyNotificationResult> {
|
|
28
|
+
return sendNtfyNotification({
|
|
29
|
+
title: stringField(args.title, "title", { required: true, max: 200 }),
|
|
30
|
+
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
31
|
+
priority: optionalEnum(args.priority, "priority", NTFY_PRIORITIES) as NtfyPriority | undefined,
|
|
32
|
+
tags: optionalStringArray(args.tags, "tags"),
|
|
33
|
+
click: optionalString(args.click, "click", 1000),
|
|
34
|
+
});
|
|
35
|
+
}
|
package/src/ntfy.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { MAX_BODY_BYTES } from "./config";
|
|
3
|
+
import { getConfig } from "./config-store";
|
|
4
|
+
import { upsertAgent, ValidationError } from "./db";
|
|
5
|
+
import { registerChannelTypeSetting } from "./settings/modules";
|
|
6
|
+
import { cleanString, cleanStringArray } from "./validation";
|
|
7
|
+
import { errMessage, isRecord, type JSONSchema } from "agent-relay-sdk";
|
|
8
|
+
|
|
9
|
+
export const NTFY_CONFIG_NAMESPACE = "channel-type";
|
|
10
|
+
export const NTFY_CONFIG_KEY = "ntfy";
|
|
11
|
+
export const NTFY_CHANNEL_ID = "ntfy:default";
|
|
12
|
+
|
|
13
|
+
export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
|
|
14
|
+
export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
|
|
15
|
+
|
|
16
|
+
export interface NtfyChannelConfig {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
serverUrl: string;
|
|
19
|
+
topic: string;
|
|
20
|
+
tokenEnv?: string;
|
|
21
|
+
username?: string;
|
|
22
|
+
passwordEnv?: string;
|
|
23
|
+
defaultTags: string[];
|
|
24
|
+
defaultClick?: string;
|
|
25
|
+
timeoutMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NtfyNotificationInput {
|
|
29
|
+
title: string;
|
|
30
|
+
body: string;
|
|
31
|
+
priority?: NtfyPriority;
|
|
32
|
+
tags?: string[];
|
|
33
|
+
click?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NtfyNotificationResult {
|
|
37
|
+
ok: true;
|
|
38
|
+
status: number;
|
|
39
|
+
channelId: string;
|
|
40
|
+
topic: string;
|
|
41
|
+
priority: NtfyPriority;
|
|
42
|
+
tags: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_NTFY_CONFIG: NtfyChannelConfig = {
|
|
46
|
+
enabled: false,
|
|
47
|
+
serverUrl: "",
|
|
48
|
+
topic: "",
|
|
49
|
+
defaultTags: [],
|
|
50
|
+
timeoutMs: 15_000,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const NTFY_CONFIG_SCHEMA: JSONSchema = {
|
|
54
|
+
type: "object",
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
required: ["enabled", "serverUrl", "topic"],
|
|
57
|
+
properties: {
|
|
58
|
+
enabled: { type: "boolean" },
|
|
59
|
+
serverUrl: { type: "string", maxLength: 500 },
|
|
60
|
+
topic: { type: "string", maxLength: 200 },
|
|
61
|
+
tokenEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
62
|
+
username: { type: "string", maxLength: 120 },
|
|
63
|
+
passwordEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
|
|
64
|
+
defaultTags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 40 } },
|
|
65
|
+
defaultClick: { type: "string", maxLength: 1000 },
|
|
66
|
+
timeoutMs: { type: "integer", minimum: 1_000, maximum: 60_000 },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
registerChannelTypeSetting({
|
|
71
|
+
type: NTFY_CONFIG_KEY,
|
|
72
|
+
schema: NTFY_CONFIG_SCHEMA,
|
|
73
|
+
defaults: DEFAULT_NTFY_CONFIG,
|
|
74
|
+
display: {
|
|
75
|
+
label: "ntfy outgoing channel",
|
|
76
|
+
description: "Configures the built-in egress-only ntfy notification channel.",
|
|
77
|
+
order: 121,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export function getNtfyConfig(): NtfyChannelConfig {
|
|
82
|
+
const stored = getConfig<Partial<NtfyChannelConfig>>(NTFY_CONFIG_NAMESPACE, NTFY_CONFIG_KEY)?.value;
|
|
83
|
+
return normalizeNtfyConfig({ ...DEFAULT_NTFY_CONFIG, ...(isRecord(stored) ? stored : {}) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sendNtfyNotification(input: NtfyNotificationInput): Promise<NtfyNotificationResult> {
|
|
87
|
+
const config = getNtfyConfig();
|
|
88
|
+
validateSendConfig(config);
|
|
89
|
+
const title = cleanString(input.title, "title", { required: true, max: 200 })!;
|
|
90
|
+
const body = cleanString(input.body, "body", { required: true, max: MAX_BODY_BYTES })!;
|
|
91
|
+
const priority = input.priority ?? "default";
|
|
92
|
+
const tags = [...new Set([...(config.defaultTags ?? []), ...(input.tags ?? [])].map((tag) => tag.trim()).filter(Boolean))];
|
|
93
|
+
const click = cleanString(input.click ?? config.defaultClick, "click", { max: 1000 });
|
|
94
|
+
const url = ntfyTopicUrl(config);
|
|
95
|
+
const headers = ntfyHeaders({ config, title, priority, tags, click });
|
|
96
|
+
|
|
97
|
+
ensureNtfyChannelRegistered(config);
|
|
98
|
+
|
|
99
|
+
const controller = new AbortController();
|
|
100
|
+
const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
101
|
+
let response: Response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetch(url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers,
|
|
106
|
+
body,
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
});
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
|
|
111
|
+
throw new Error(`ntfy notify failed: ${errMessage(error)}`);
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const text = await response.text().catch(() => "");
|
|
118
|
+
throw new Error(`ntfy notify failed: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, status: response.status, channelId: NTFY_CHANNEL_ID, topic: config.topic, priority, tags };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function normalizeNtfyConfig(value: unknown): NtfyChannelConfig {
|
|
125
|
+
if (!isRecord(value)) throw new ValidationError("ntfy channel config must be an object");
|
|
126
|
+
const timeoutMs = value.timeoutMs === undefined ? DEFAULT_NTFY_CONFIG.timeoutMs : value.timeoutMs;
|
|
127
|
+
if (typeof value.enabled !== "boolean") throw new ValidationError("ntfy.enabled must be a boolean");
|
|
128
|
+
if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
|
|
129
|
+
throw new ValidationError("ntfy.timeoutMs must be an integer between 1000 and 60000");
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
enabled: value.enabled,
|
|
133
|
+
serverUrl: cleanString(value.serverUrl, "ntfy.serverUrl", { max: 500 }) ?? "",
|
|
134
|
+
topic: cleanString(value.topic, "ntfy.topic", { max: 200 }) ?? "",
|
|
135
|
+
tokenEnv: cleanString(value.tokenEnv, "ntfy.tokenEnv", { max: 120 }),
|
|
136
|
+
username: cleanString(value.username, "ntfy.username", { max: 120 }),
|
|
137
|
+
passwordEnv: cleanString(value.passwordEnv, "ntfy.passwordEnv", { max: 120 }),
|
|
138
|
+
defaultTags: cleanStringArray(value.defaultTags, "ntfy.defaultTags", { itemMax: 40, maxItems: 20 }) ?? [],
|
|
139
|
+
defaultClick: cleanString(value.defaultClick, "ntfy.defaultClick", { max: 1000 }),
|
|
140
|
+
timeoutMs,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validateSendConfig(config: NtfyChannelConfig): void {
|
|
145
|
+
if (!config.enabled) throw new ValidationError("ntfy channel is disabled");
|
|
146
|
+
if (!config.serverUrl || !config.topic) {
|
|
147
|
+
throw new ValidationError("ntfy channel requires channel-type/ntfy serverUrl and topic");
|
|
148
|
+
}
|
|
149
|
+
if (config.topic.includes("/") || config.topic.includes("?") || config.topic.includes("#")) {
|
|
150
|
+
throw new ValidationError("ntfy.topic must be a single topic name, not a path or URL");
|
|
151
|
+
}
|
|
152
|
+
if (config.tokenEnv && (config.username || config.passwordEnv)) {
|
|
153
|
+
throw new ValidationError("ntfy auth must use tokenEnv or username/passwordEnv, not both");
|
|
154
|
+
}
|
|
155
|
+
if (config.passwordEnv && !config.username) throw new ValidationError("ntfy.username required when passwordEnv is set");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ntfyTopicUrl(config: NtfyChannelConfig): string {
|
|
159
|
+
let base: URL;
|
|
160
|
+
try {
|
|
161
|
+
base = new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`);
|
|
162
|
+
} catch {
|
|
163
|
+
throw new ValidationError("ntfy.serverUrl must be a valid URL");
|
|
164
|
+
}
|
|
165
|
+
return new URL(encodeURIComponent(config.topic), base).toString();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ntfyHeaders(input: {
|
|
169
|
+
config: NtfyChannelConfig;
|
|
170
|
+
title: string;
|
|
171
|
+
priority: NtfyPriority;
|
|
172
|
+
tags: string[];
|
|
173
|
+
click?: string;
|
|
174
|
+
}): Record<string, string> {
|
|
175
|
+
const headers: Record<string, string> = {
|
|
176
|
+
Title: input.title,
|
|
177
|
+
Priority: String(priorityValue(input.priority)),
|
|
178
|
+
};
|
|
179
|
+
if (input.tags.length) headers.Tags = input.tags.join(",");
|
|
180
|
+
if (input.click) headers.Click = input.click;
|
|
181
|
+
const authorization = authHeader(input.config);
|
|
182
|
+
if (authorization) headers.Authorization = authorization;
|
|
183
|
+
return headers;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function authHeader(config: NtfyChannelConfig): string | undefined {
|
|
187
|
+
if (config.tokenEnv) {
|
|
188
|
+
const token = cleanString(process.env[config.tokenEnv], config.tokenEnv, { required: true, max: 4_000 })!;
|
|
189
|
+
return `Bearer ${token}`;
|
|
190
|
+
}
|
|
191
|
+
if (config.username && config.passwordEnv) {
|
|
192
|
+
const password = cleanString(process.env[config.passwordEnv], config.passwordEnv, { required: true, max: 4_000 })!;
|
|
193
|
+
return `Basic ${Buffer.from(`${config.username}:${password}`).toString("base64")}`;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function priorityValue(priority: NtfyPriority): number {
|
|
199
|
+
return NTFY_PRIORITIES.indexOf(priority) + 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function ensureNtfyChannelRegistered(config: NtfyChannelConfig): void {
|
|
203
|
+
upsertAgent({
|
|
204
|
+
id: NTFY_CHANNEL_ID,
|
|
205
|
+
name: "ntfy",
|
|
206
|
+
kind: "channel",
|
|
207
|
+
tags: ["channel", "ntfy", "channel:ntfy"],
|
|
208
|
+
capabilities: ["channel", "ntfy", "notify"],
|
|
209
|
+
status: "online",
|
|
210
|
+
ready: true,
|
|
211
|
+
meta: {
|
|
212
|
+
kind: "channel",
|
|
213
|
+
provider: "ntfy",
|
|
214
|
+
accountId: "default",
|
|
215
|
+
channelType: "ntfy",
|
|
216
|
+
transport: "ntfy",
|
|
217
|
+
direction: "outbound",
|
|
218
|
+
displayName: "ntfy",
|
|
219
|
+
topicChannels: [config.topic],
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
package/src/quota-advisory.ts
CHANGED
|
@@ -29,6 +29,7 @@ type AgentRecipientRow = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
const RESET_ROUNDING_PERCENT = 99;
|
|
32
|
+
const QUOTA_THRESHOLD_EPSILON = 1e-9;
|
|
32
33
|
|
|
33
34
|
export function installQuotaAdvisoryHandler(): void {
|
|
34
35
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
@@ -62,14 +63,14 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
62
63
|
continue;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
if (remaining
|
|
66
|
+
if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
|
|
66
67
|
if (previous) delete nextState[window.name];
|
|
67
68
|
continue;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const band = quotaRemainingBand(remaining, config.bandSize);
|
|
71
72
|
if (previous?.lastQuotaAdvisoryBand === band) continue;
|
|
72
|
-
const stage: QuotaAdvisoryStage = remaining
|
|
73
|
+
const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
|
|
73
74
|
notifyQuotaAdvisory({
|
|
74
75
|
provider: input.provider,
|
|
75
76
|
accountKey: input.accountKey,
|
|
@@ -4,7 +4,7 @@ import type { NotificationsConfig } from "./types";
|
|
|
4
4
|
|
|
5
5
|
export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
|
|
6
6
|
enabled: true,
|
|
7
|
-
warnRemaining: 0.
|
|
7
|
+
warnRemaining: 0.15,
|
|
8
8
|
criticalRemaining: 0.1,
|
|
9
9
|
resetRemaining: 0.99,
|
|
10
10
|
bandSize: 0.05,
|
package/src/routes/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
|
|
|
14
14
|
import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
|
|
15
15
|
import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
|
|
16
16
|
import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
|
|
17
|
-
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
17
|
+
import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
|
|
18
18
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
19
19
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
20
20
|
import { error, type Handler } from "./_shared";
|
|
@@ -259,6 +259,7 @@ const routes: Route[] = [
|
|
|
259
259
|
route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
|
|
260
260
|
route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
|
|
261
261
|
route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
|
|
262
|
+
route("GET", "/api/spawn-policy/:name/diagnostics", getSpawnPolicyDiagnostics),
|
|
262
263
|
route("GET", "/api/providers", getProvidersRoute),
|
|
263
264
|
route("GET", "/api/providers/config", getProviderConfigsRoute),
|
|
264
265
|
route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
|