diff --git a/.circleci/config.yml b/.circleci/config.yml index 82444cbf9d..28bb39f742 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -102,6 +102,42 @@ jobs: working_directory: packages/shared - store_test_results: path: ./test-results + strict_typecheck_report: + docker: + - image: cimg/node:22.22 + resource_class: large + steps: + - checkout + - restore_cache: + keys: + - deps-v7-{{ checksum "pnpm-lock.yaml" }} + - deps-v7-{{ .Branch }} + - run: + name: Strict Typecheck Report (non-blocking) + command: | + mkdir -p strict-typecheck + set +e + + npm run typecheck:strict --prefix packages/shared > strict-typecheck/shared.log 2>&1 + SHARED_EXIT=$? + npm run typecheck:strict --prefix packages/webapp > strict-typecheck/webapp.log 2>&1 + WEBAPP_EXIT=$? + npm run typecheck:strict --prefix packages/extension > strict-typecheck/extension.log 2>&1 + EXTENSION_EXIT=$? + + echo "shared exit code: ${SHARED_EXIT}" + echo "webapp exit code: ${WEBAPP_EXIT}" + echo "extension exit code: ${EXTENSION_EXIT}" + + for file in strict-typecheck/shared.log strict-typecheck/webapp.log strict-typecheck/extension.log; do + echo "--- ${file} ---" + grep -Eo 'error TS[0-9]+' "${file}" | sort | uniq -c | sort -nr | head -10 || true + echo "total errors: $(grep -Ec 'error TS[0-9]+' "${file}" || true)" + done + + exit 0 + - store_artifacts: + path: strict-typecheck build_extension: docker: - image: cimg/node:22.22 @@ -136,6 +172,9 @@ workflows: - test_shared: requires: - install_deps + - strict_typecheck_report: + requires: + - install_deps - build_extension: requires: - install_deps diff --git a/package.json b/package.json index 2574321a2e..632b982e56 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "scripts": { "prepare": "corepack enable || true", "pnpm-version": "pnpm -v", + "typecheck": "pnpm --filter @dailydotdev/shared typecheck && pnpm --filter webapp typecheck && pnpm --filter extension typecheck", + "typecheck:strict": "pnpm --filter @dailydotdev/shared typecheck:strict && pnpm --filter webapp typecheck:strict && pnpm --filter extension typecheck:strict", + "typecheck:strict:report": "pnpm --filter @dailydotdev/shared typecheck:strict || true; pnpm --filter webapp typecheck:strict || true; pnpm --filter extension typecheck:strict || true", "test:e2e": "pnpm --filter playwright test", "test:e2e:headed": "pnpm --filter playwright test:headed", "test:e2e:ui": "pnpm --filter playwright test:ui" diff --git a/packages/extension/package.json b/packages/extension/package.json index 697f564a4e..923fa1c4ea 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -6,6 +6,8 @@ "build": "cross-env NODE_ENV=production cross-env TARGET_BROWSER=chrome webpack", "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0 --fix", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:strict": "tsc -p tsconfig.strict.json --noEmit", "pretest": "npm run lint", "test": "jest --runInBand" }, diff --git a/packages/extension/tsconfig.strict.json b/packages/extension/tsconfig.strict.json new file mode 100644 index 0000000000..3b1cb1d1bc --- /dev/null +++ b/packages/extension/tsconfig.strict.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": true + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json index ad844bcc7c..0fcb3c3d85 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -6,6 +6,8 @@ "scripts": { "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix --max-warnings 0", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:strict": "tsc -p tsconfig.strict.json --noEmit", "pretest": "npm run lint", "test": "jest --runInBand" }, diff --git a/packages/shared/src/components/layout/HeaderButtons.tsx b/packages/shared/src/components/layout/HeaderButtons.tsx index 2dc74da71b..b6f658081b 100644 --- a/packages/shared/src/components/layout/HeaderButtons.tsx +++ b/packages/shared/src/components/layout/HeaderButtons.tsx @@ -7,6 +7,7 @@ import { useAuthContext } from '../../contexts/AuthContext'; import classed from '../../lib/classed'; import { useSettingsContext } from '../../contexts/SettingsContext'; import { OpportunityEntryButton } from '../opportunity/OpportunityEntryButton'; +import { AgentStatusIndicator } from '../../features/agentStatus/components/AgentStatusIndicator'; interface HeaderButtonsProps { additionalButtons?: ReactNode; @@ -40,6 +41,7 @@ export function HeaderButtons({ return ( + {additionalButtons} diff --git a/packages/shared/src/features/agentStatus/components/AgentStatusIndicator.tsx b/packages/shared/src/features/agentStatus/components/AgentStatusIndicator.tsx new file mode 100644 index 0000000000..f1714afbda --- /dev/null +++ b/packages/shared/src/features/agentStatus/components/AgentStatusIndicator.tsx @@ -0,0 +1,190 @@ +import type { ReactElement } from 'react'; +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Button, ButtonVariant } from '../../../components/buttons/Button'; +import { TerminalIcon } from '../../../components/icons'; +import { Tooltip } from '../../../components/tooltip/Tooltip'; +import { SimpleTooltip } from '../../../components/tooltips/SimpleTooltip'; +import { useInteractivePopup } from '../../../hooks/utils/useInteractivePopup'; +import { useToastNotification } from '../../../hooks/useToastNotification'; +import { useAgentStatus } from '../hooks/useAgentStatus'; +import { AgentStatusType } from '../types'; +import { AgentStatusPopup } from './AgentStatusPopup'; + +export function AgentStatusIndicator(): ReactElement { + const { agents, isLoading } = useAgentStatus(); + const { displayToast } = useToastNotification(); + const { isOpen, onUpdate, wrapHandler } = useInteractivePopup(); + const previousStatusesRef = useRef>(new Map()); + const initializedRef = useRef(false); + + const activeAgents = agents.filter( + (agent) => agent.status !== AgentStatusType.Idle, + ); + const waitingAgents = activeAgents.filter( + (agent) => agent.status === AgentStatusType.Waiting, + ); + const completedAgents = activeAgents.filter( + (agent) => agent.status === AgentStatusType.Completed, + ); + const errorAgents = activeAgents.filter( + (agent) => agent.status === AgentStatusType.Error, + ); + const runningAgents = activeAgents.filter( + (agent) => agent.status === AgentStatusType.Working, + ); + + const hasAgents = activeAgents.length > 0; + const waitingCount = waitingAgents.length; + const errorCount = errorAgents.length; + const completedCount = completedAgents.length; + const runningCount = runningAgents.length; + const hasWaiting = waitingCount > 0; + const hasError = errorCount > 0; + const hasWorking = runningCount > 0; + const hasCompleted = completedCount > 0; + + const formatCount = (count: number, copy: string): string => { + return `${count} agent${count > 1 ? 's' : ''} ${copy}`; + }; + + useEffect(() => { + if (!initializedRef.current) { + if (isLoading) { + return; + } + + previousStatusesRef.current = new Map( + agents.map((agent) => [`${agent.name}:${agent.project}`, agent.status]), + ); + initializedRef.current = true; + return; + } + + const previousStatuses = previousStatusesRef.current; + const nextStatuses = new Map(); + const newWaitingAgents: string[] = []; + const newCompletedAgents: string[] = []; + + agents.forEach((agent) => { + const agentId = `${agent.name}:${agent.project}`; + const previousStatus = previousStatuses.get(agentId); + + nextStatuses.set(agentId, agent.status); + + if (!previousStatus || previousStatus === agent.status) { + return; + } + + if (agent.status === AgentStatusType.Waiting) { + newWaitingAgents.push(agent.name); + return; + } + + if (agent.status === AgentStatusType.Completed) { + newCompletedAgents.push(agent.name); + } + }); + + previousStatusesRef.current = nextStatuses; + + if (newWaitingAgents.length > 0) { + const firstAgent = newWaitingAgents[0]; + const message = + newWaitingAgents.length === 1 + ? `${firstAgent} needs your input` + : `${newWaitingAgents.length} agents need your input`; + + displayToast(message); + return; + } + + if (newCompletedAgents.length > 0) { + const firstAgent = newCompletedAgents[0]; + const message = + newCompletedAgents.length === 1 + ? `${firstAgent} is done` + : `${newCompletedAgents.length} agents are done`; + + displayToast(message); + } + }, [agents, displayToast, isLoading]); + + let dotColor = 'bg-text-disabled'; + if (hasWaiting) { + dotColor = 'bg-status-warning'; + } else if (hasError) { + dotColor = 'bg-status-error'; + } else if (hasWorking) { + dotColor = 'bg-status-success'; + } else if (hasCompleted) { + dotColor = 'bg-brand-default'; + } + + let tooltipContent = 'No active agents'; + if (hasWaiting) { + tooltipContent = formatCount(waitingCount, 'need input'); + } else if (hasError) { + tooltipContent = formatCount(errorCount, 'need attention'); + } else if (hasWorking) { + tooltipContent = formatCount(runningCount, 'running'); + } else if (hasAgents) { + tooltipContent = formatCount(completedCount, 'done'); + } + + return ( + onUpdate(false)} + showArrow={false} + container={{ + className: + 'w-72 !rounded-14 border border-border-subtlest-tertiary !bg-accent-pepper-subtlest', + bgClassName: 'bg-accent-pepper-subtlest', + textClassName: 'text-text-primary', + paddingClassName: 'p-0', + }} + content={} + > +
+ +
+
+ ); +} diff --git a/packages/shared/src/features/agentStatus/components/AgentStatusPanel.tsx b/packages/shared/src/features/agentStatus/components/AgentStatusPanel.tsx new file mode 100644 index 0000000000..b2bf95990b --- /dev/null +++ b/packages/shared/src/features/agentStatus/components/AgentStatusPanel.tsx @@ -0,0 +1,244 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyColor, + TypographyType, +} from '../../../components/typography/Typography'; +import type { AgentStatus } from '../types'; +import { AgentStatusType } from '../types'; + +const statusConfig: Record< + AgentStatusType, + { + label: string; + dotClass: string; + labelClass: string; + borderClass: string; + badgeClass: string; + } +> = { + [AgentStatusType.Working]: { + label: 'Running', + dotClass: 'bg-status-success', + labelClass: 'text-status-success', + borderClass: 'border-l-status-success', + badgeClass: + 'bg-status-success/16 text-status-success border border-status-success/24', + }, + [AgentStatusType.Waiting]: { + label: 'Needs input', + dotClass: 'bg-status-warning', + labelClass: 'text-status-warning', + borderClass: 'border-l-status-warning', + badgeClass: + 'bg-status-warning/16 text-status-warning border border-status-warning/24', + }, + [AgentStatusType.Error]: { + label: 'Needs attention', + dotClass: 'bg-status-error', + labelClass: 'text-status-error', + borderClass: 'border-l-status-error', + badgeClass: + 'bg-status-error/16 text-status-error border border-status-error/24', + }, + [AgentStatusType.Completed]: { + label: 'Done', + dotClass: 'bg-status-success', + labelClass: 'text-status-success', + borderClass: 'border-l-brand-default', + badgeClass: + 'bg-brand-default/16 text-brand-default border border-brand-default/24', + }, + [AgentStatusType.Idle]: { + label: 'Idle', + dotClass: 'bg-text-disabled', + labelClass: 'text-text-disabled', + borderClass: 'border-l-text-disabled', + badgeClass: + 'bg-text-disabled/16 text-text-disabled border border-text-disabled/24', + }, +}; + +function StatusDot({ status }: { status: AgentStatusType }): ReactElement { + const config = statusConfig[status] ?? statusConfig[AgentStatusType.Idle]; + const isWaiting = status === AgentStatusType.Waiting; + const isWorking = status === AgentStatusType.Working; + + if (isWaiting) { + return ( + + + + + ); + } + + return ( + + ); +} + +function AgentRow({ agent }: { agent: AgentStatus }): ReactElement { + const config = + statusConfig[agent.status] ?? statusConfig[AgentStatusType.Idle]; + const isWaiting = agent.status === AgentStatusType.Waiting; + const details = agent.message || agent.task; + + let rowClass = + 'border-border-subtlest-tertiary bg-surface-float hover:bg-surface-hover'; + + if (isWaiting) { + rowClass = 'border-status-warning/24 bg-status-warning/8'; + } + + return ( +
+
+ +
+
+
+ + {agent.name} + + + {config.label} + +
+ {agent.project && ( + + {agent.project} + + )} + {details && ( + + {details} + + )} +
+
+ ); +} + +interface AgentStatusPanelProps { + agents: AgentStatus[]; +} + +const byNewest = (a: AgentStatus, b: AgentStatus): number => { + return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(); +}; + +export function AgentStatusPanel({ + agents, +}: AgentStatusPanelProps): ReactElement { + const waitingAgents = agents + .filter((agent) => agent.status === AgentStatusType.Waiting) + .sort(byNewest); + const runningAgents = agents + .filter((agent) => agent.status === AgentStatusType.Working) + .sort(byNewest); + const errorAgents = agents + .filter((agent) => agent.status === AgentStatusType.Error) + .sort(byNewest); + const completedAgents = agents + .filter((agent) => agent.status === AgentStatusType.Completed) + .sort(byNewest); + + if (agents.length === 0) { + return ( +
+ + No active agents + + + Status will appear when an agent starts working + +
+ ); + } + + const sections: { title: string; agents: AgentStatus[] }[] = [ + { title: 'Needs your input', agents: waitingAgents }, + { title: 'Running', agents: runningAgents }, + { title: 'Issues', agents: errorAgents }, + { title: 'Done', agents: completedAgents }, + ]; + + return ( +
+ {sections.map((section) => { + if (section.agents.length === 0) { + return null; + } + + return ( +
+ + {section.title} ({section.agents.length}) + + {section.agents.map((agent) => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/packages/shared/src/features/agentStatus/components/AgentStatusPopup.tsx b/packages/shared/src/features/agentStatus/components/AgentStatusPopup.tsx new file mode 100644 index 0000000000..635ec587ab --- /dev/null +++ b/packages/shared/src/features/agentStatus/components/AgentStatusPopup.tsx @@ -0,0 +1,85 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, +} from '../../../components/typography/Typography'; +import { TerminalIcon } from '../../../components/icons'; +import type { AgentStatus } from '../types'; +import { AgentStatusType } from '../types'; +import { AgentStatusPanel } from './AgentStatusPanel'; + +interface SummaryBadgeProps { + count: number; + label: string; + className: string; +} + +function SummaryBadge({ + count, + label, + className, +}: SummaryBadgeProps): ReactElement | null { + if (count === 0) { + return null; + } + + return ( + + {count} {label} + + ); +} + +interface AgentStatusPopupProps { + agents: AgentStatus[]; +} + +export function AgentStatusPopup({ + agents, +}: AgentStatusPopupProps): ReactElement { + const waitingCount = agents.filter( + (agent) => agent.status === AgentStatusType.Waiting, + ).length; + const runningCount = agents.filter( + (agent) => agent.status === AgentStatusType.Working, + ).length; + const doneCount = agents.filter( + (agent) => agent.status === AgentStatusType.Completed, + ).length; + + return ( +
+
+ + + Agent Status + +
+ + + +
+
+ +
+ ); +} diff --git a/packages/shared/src/features/agentStatus/hooks/useAgentStatus.ts b/packages/shared/src/features/agentStatus/hooks/useAgentStatus.ts new file mode 100644 index 0000000000..1cf14e54e9 --- /dev/null +++ b/packages/shared/src/features/agentStatus/hooks/useAgentStatus.ts @@ -0,0 +1,124 @@ +import { useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { gqlClient } from '../../../graphql/common'; +import { generateQueryKey, RequestKey } from '../../../lib/query'; +import { useAuthContext } from '../../../contexts/AuthContext'; +import useSubscription from '../../../hooks/useSubscription'; +import type { AgentStatus } from '../types'; +import { + AGENT_STATUS_QUERY, + AGENT_STATUS_SUBSCRIPTION, + AgentStatusType, +} from '../types'; + +interface AgentStatusData { + agentStatus: AgentStatus[]; +} + +interface AgentStatusSubscriptionData { + agentStatusUpdated: AgentStatus[] | AgentStatus; +} + +interface UseAgentStatusReturn { + agents: AgentStatus[]; + isLoading: boolean; +} + +const IS_DEV = process.env.NODE_ENV === 'development'; + +const STATUS_ALIASES: Record = { + [AgentStatusType.Working]: AgentStatusType.Working, + running: AgentStatusType.Working, + in_progress: AgentStatusType.Working, + processing: AgentStatusType.Working, + [AgentStatusType.Waiting]: AgentStatusType.Waiting, + needs_input: AgentStatusType.Waiting, + waiting_for_input: AgentStatusType.Waiting, + input_required: AgentStatusType.Waiting, + [AgentStatusType.Error]: AgentStatusType.Error, + failed: AgentStatusType.Error, + failure: AgentStatusType.Error, + [AgentStatusType.Completed]: AgentStatusType.Completed, + done: AgentStatusType.Completed, + finished: AgentStatusType.Completed, + success: AgentStatusType.Completed, + [AgentStatusType.Idle]: AgentStatusType.Idle, + inactive: AgentStatusType.Idle, + stopped: AgentStatusType.Idle, +}; + +const normalizeStatus = (status?: string): AgentStatusType => { + if (!status) { + return AgentStatusType.Idle; + } + + return STATUS_ALIASES[status.toLowerCase()] ?? AgentStatusType.Idle; +}; + +const normalizeAgents = (agents: AgentStatus[]): AgentStatus[] => { + return agents.map((agent) => ({ + ...agent, + status: normalizeStatus(String(agent.status)), + })); +}; + +const toArray = (agentStatus: AgentStatus[] | AgentStatus): AgentStatus[] => { + return Array.isArray(agentStatus) ? agentStatus : [agentStatus]; +}; + +export function useAgentStatus(): UseAgentStatusReturn { + const { user } = useAuthContext(); + const queryClient = useQueryClient(); + const queryKey = generateQueryKey(RequestKey.AgentStatus, user); + + const { data, isLoading } = useQuery({ + queryKey, + queryFn: async () => { + if (IS_DEV) { + // In dev, poll the local Next.js API route that hooks POST to + const res = await fetch('/api/agent-status'); + const json = (await res.json()) as AgentStatusData; + + return { + agentStatus: normalizeAgents(json.agentStatus ?? []), + }; + } + + const response = await gqlClient.request( + AGENT_STATUS_QUERY, + ); + + return { + agentStatus: normalizeAgents(response.agentStatus ?? []), + }; + }, + enabled: IS_DEV || !!user, + refetchInterval: IS_DEV ? 2_000 : 30_000, + }); + + const onSubscriptionData = useCallback( + (subscriptionData: AgentStatusSubscriptionData) => { + const agentStatus = subscriptionData.agentStatusUpdated; + const normalizedAgents = agentStatus + ? normalizeAgents(toArray(agentStatus)) + : []; + + queryClient.setQueryData(queryKey, () => ({ + agentStatus: normalizedAgents, + })); + }, + [queryClient, queryKey], + ); + + // In production, use GraphQL subscription for real-time updates + useSubscription( + () => ({ query: AGENT_STATUS_SUBSCRIPTION }), + { next: onSubscriptionData }, + [onSubscriptionData], + ); + + return { + agents: data?.agentStatus ?? [], + isLoading, + }; +} diff --git a/packages/shared/src/features/agentStatus/types.ts b/packages/shared/src/features/agentStatus/types.ts new file mode 100644 index 0000000000..d79af888e2 --- /dev/null +++ b/packages/shared/src/features/agentStatus/types.ts @@ -0,0 +1,42 @@ +export interface AgentStatus { + name: string; + project: string; + status: AgentStatusType; + task: string; + message?: string; + timestamp: string; +} + +export enum AgentStatusType { + Working = 'working', + Waiting = 'waiting', + Error = 'error', + Completed = 'completed', + Idle = 'idle', +} + +export const AGENT_STATUS_QUERY = ` + query AgentStatus { + agentStatus { + name + project + status + task + message + timestamp + } + } +`; + +export const AGENT_STATUS_SUBSCRIPTION = ` + subscription AgentStatusUpdated { + agentStatusUpdated { + name + project + status + task + message + timestamp + } + } +`; diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index f4e40979d5..cb94f210bf 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -243,6 +243,7 @@ export enum RequestKey { Gear = 'gear', GearSearch = 'gear_search', PersonalAccessTokens = 'personal_access_tokens', + AgentStatus = 'agent_status', } export const getPostByIdKey = (id: string): QueryKey => [RequestKey.Post, id]; diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts index 89a82ff1fc..455cfdba3f 100644 --- a/packages/shared/tailwind.config.ts +++ b/packages/shared/tailwind.config.ts @@ -252,12 +252,22 @@ export default { backgroundColor: 'transparent', }, }, + breathe: { + '0%, 100%': { transform: 'scale(1)', opacity: '1' }, + '50%': { transform: 'scale(1.5)', opacity: '0.6' }, + }, + 'glow-ring': { + '0%': { transform: 'scale(1)', opacity: '0.7' }, + '100%': { transform: 'scale(2.5)', opacity: '0' }, + }, }, animation: { 'scale-down-pulse': 'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both', 'highlight-fade': 'highlight-fade 2.5s ease-out forwards', + breathe: 'breathe 3s ease-in-out infinite', + 'glow-ring': 'glow-ring 2s ease-out infinite', }, }, lineClamp: { diff --git a/packages/shared/tsconfig.strict.json b/packages/shared/tsconfig.strict.json new file mode 100644 index 0000000000..3b1cb1d1bc --- /dev/null +++ b/packages/shared/tsconfig.strict.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": true + } +} diff --git a/packages/webapp/package.json b/packages/webapp/package.json index 990a94c3f8..5248e32896 100644 --- a/packages/webapp/package.json +++ b/packages/webapp/package.json @@ -10,6 +10,8 @@ "start": "next start", "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0 --fix", "lint": "eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0", + "typecheck": "tsc -p tsconfig.json --noEmit", + "typecheck:strict": "tsc -p tsconfig.strict.json --noEmit", "pretest": "npm run lint", "test": "jest --runInBand", "deploy": "vercel" diff --git a/packages/webapp/pages/api/agent-status/index.ts b/packages/webapp/pages/api/agent-status/index.ts new file mode 100644 index 0000000000..3755c2ab0e --- /dev/null +++ b/packages/webapp/pages/api/agent-status/index.ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from 'next'; + +// In-memory store — persists across requests within the same dev server process +let agentStatusStore: Record[] = []; +let lastUpdated = 0; + +const handler = (req: NextApiRequest, res: NextApiResponse): void => { + // Allow cross-origin for hooks running from terminal + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') { + res.status(200).end(); + return; + } + + try { + if (req.method === 'POST') { + const { agents } = req.body || {}; + + if (!Array.isArray(agents)) { + res.status(400).json({ error: 'agents array required' }); + return; + } + + agentStatusStore = agents; + lastUpdated = Date.now(); + res.status(200).json({ status: 'ok' }); + return; + } + + if (req.method === 'GET') { + // Expire after 2 minutes of no updates + if (lastUpdated && Date.now() - lastUpdated > 120_000) { + agentStatusStore = []; + } + + res.status(200).json({ agentStatus: agentStatusStore }); + return; + } + + res.status(405).json({ error: 'method not allowed' }); + } catch { + res.status(500).json({ error: 'internal server error' }); + } +}; + +export default handler; diff --git a/packages/webapp/tsconfig.strict.json b/packages/webapp/tsconfig.strict.json new file mode 100644 index 0000000000..c89eab7548 --- /dev/null +++ b/packages/webapp/tsconfig.strict.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strict": true, + "strictNullChecks": true + } +}