diff --git a/packages/agent/src/__tests__/events.test.ts b/packages/agent/src/__tests__/events.test.ts new file mode 100644 index 0000000..01eed88 --- /dev/null +++ b/packages/agent/src/__tests__/events.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { AgentLifecycleEvent } from '@astroid/types'; + +import { AgentResource } from '../index.js'; + +function createClientMock() { + return { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }; +} + +function makeEvent(overrides: Partial = {}): AgentLifecycleEvent { + return { + id: 'evt_1', + type: 'agent.created', + agentId: 'agt_1', + organizationId: 'org_1', + occurredAt: '2026-08-29T10:00:00.000Z', + payload: { + agentId: 'agt_1', + organizationId: 'org_1', + occurredAt: '2026-08-29T10:00:00.000Z', + }, + ...overrides, + }; +} + +describe('AgentResource event endpoints', () => { + let http: ReturnType; + let resource: AgentResource; + + beforeEach(() => { + http = createClientMock(); + resource = new AgentResource(http as never); + }); + + it('listEvents() GETs the agent events path with an encoded id', async () => { + http.get.mockResolvedValue({ data: [makeEvent()] }); + await resource.listEvents('agt/1'); + expect(http.get).toHaveBeenCalledWith('/v1/agents/agt%2F1/events', { query: undefined }); + }); + + it('listEvents() serializes filters and pagination as a query', async () => { + http.get.mockResolvedValue({ data: [makeEvent()] }); + await resource.listEvents('agt_1', { + eventTypes: ['agent.created', 'agent.suspended'], + cursor: 'c1', + limit: 25, + order: 'desc', + from: '2026-08-01T00:00:00.000Z', + }); + expect(http.get).toHaveBeenCalledWith('/v1/agents/agt_1/events', { + query: { + cursor: 'c1', + limit: 25, + order: 'desc', + from: '2026-08-01T00:00:00.000Z', + eventTypes: 'agent.created,agent.suspended', + }, + }); + }); + + it('listEvents() omits empty filters', async () => { + http.get.mockResolvedValue({ data: [makeEvent()] }); + await resource.listEvents('agt_1', { eventTypes: [] }); + expect(http.get).toHaveBeenCalledWith('/v1/agents/agt_1/events', { query: {} }); + }); + + it('subscribe() POSTs the subscription options to the subscriptions path', async () => { + const subscription = { + id: 'sub_1', + agentId: 'agt_1', + organizationId: 'org_1', + eventTypes: ['agent.budget_exhausted'] as const, + includeHistory: true, + status: 'ACTIVE' as const, + createdAt: '2026-08-29T10:00:00.000Z', + }; + http.post.mockResolvedValue(subscription); + const result = await resource.subscribe('agt_1', { + eventTypes: ['agent.budget_exhausted'], + includeHistory: true, + }); + expect(http.post).toHaveBeenCalledWith('/v1/agents/agt_1/events/subscriptions', { + eventTypes: ['agent.budget_exhausted'], + includeHistory: true, + }); + expect(result).toBe(subscription); + }); + + it('subscribe() defaults to an empty options payload', async () => { + http.post.mockResolvedValue({}); + await resource.subscribe('agt_1'); + expect(http.post).toHaveBeenCalledWith('/v1/agents/agt_1/events/subscriptions', {}); + }); + + it('unsubscribe() DELETEs the subscription path with encoded ids', async () => { + http.delete.mockResolvedValue(undefined); + await resource.unsubscribe('agt/1', 'sub/1'); + expect(http.delete).toHaveBeenCalledWith('/v1/agents/agt%2F1/events/subscriptions/sub%2F1'); + }); +}); diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index d8be969..f1dde08 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,12 +1,13 @@ import { Resource } from '@astroid/core'; import type { Agent, - AgentActivity, - AgentLifecycleEventRecord, + AgentEventSubscription, AgentEventSubscriptionOptions, - AgentStatus, - CreateAgentInput, - Paginated, + AgentLifecycleEvent, + CreateAgentParams, + ListAgentEventsParams, + PaginatedResponse, + PaginationParams, UpdateAgentParams, } from '@astroid/types'; import { validateCreateAgentParams } from './validation.js'; @@ -166,6 +167,75 @@ export class AgentResource extends Resource { options.signal?.removeEventListener('abort', abort); }; } + + /* ------------------------------------------------------------------------ */ + /* Lifecycle event stream */ + /* ------------------------------------------------------------------------ */ + + /** + * Page through an agent's lifecycle events (creation, suspension, resumption, + * budget exhaustion). + * + * @param agentId The unique agent ID. + * @param params Optional event-type filters and pagination. + * @returns A paginated list of lifecycle events. + */ + async listEvents( + agentId: string, + params?: ListAgentEventsParams, + ): Promise> { + return this.client.get>( + `/v1/agents/${encodeURIComponent(agentId)}/events`, + { query: toAgentEventQuery(params) }, + ); + } + + /** + * Create a subscription to an agent's lifecycle event stream. + * + * @param agentId The unique agent ID. + * @param options Which event types to receive and whether to replay history. + * @returns The created subscription. + */ + async subscribe( + agentId: string, + options: AgentEventSubscriptionOptions = {}, + ): Promise { + return this.client.post( + `/v1/agents/${encodeURIComponent(agentId)}/events/subscriptions`, + options, + ); + } + + /** + * Remove a subscription to an agent's lifecycle event stream. + * + * @param agentId The unique agent ID. + * @param subscriptionId The subscription to remove. + */ + async unsubscribe(agentId: string, subscriptionId: string): Promise { + await this.client.delete( + `/v1/agents/${encodeURIComponent(agentId)}/events/subscriptions/${encodeURIComponent(subscriptionId)}`, + ); + } +} + +/** + * Drop `undefined` / `null` entries so they never reach the query string, and + * serialise event-type filters as a comma-separated list. + */ +function toAgentEventQuery(params?: ListAgentEventsParams): Record | undefined { + if (!params) return undefined; + const query: Record = {}; + if (params.cursor !== undefined) query['cursor'] = params.cursor; + if (params.limit !== undefined) query['limit'] = params.limit; + if (params.order !== undefined) query['order'] = params.order; + if (params.from !== undefined) query['from'] = params.from; + if (params.to !== undefined) query['to'] = params.to; + if (params.eventTypes !== undefined && params.eventTypes.length > 0) { + query['eventTypes'] = params.eventTypes.join(','); + } + return query; } /** Alias of {@link AgentResource} matching the `*sResource` client naming. */ diff --git a/packages/analytics/__tests__/resource.test.ts b/packages/analytics/__tests__/resource.test.ts index 8c75eb2..a4a6b33 100644 --- a/packages/analytics/__tests__/resource.test.ts +++ b/packages/analytics/__tests__/resource.test.ts @@ -45,4 +45,41 @@ describe('AnalyticsResource', () => { expect(mockGet).toHaveBeenCalledWith('/analytics/summary?timeframe=day&asset=XLM'); }); + + it('serializes pagination params on getMetrics', async () => { + const mockGet = vi.fn().mockResolvedValue({ points: [], summary: { timeframe: 'day', totalVolume: '0', totalFees: '0', transactionCount: 0, successRate: 0, averageLatencyMs: 0 } }); + const client = { get: mockGet } as unknown as HttpClient; + const resource = new AnalyticsResource(client); + + await resource.getMetrics({ asset: 'USDC', cursor: 'c1', limit: 50, order: 'desc' }); + + const calledUrl = mockGet.mock.calls[0]![0]; + expect(calledUrl).toContain('/analytics/metrics?'); + expect(calledUrl).toContain('cursor=c1'); + expect(calledUrl).toContain('limit=50'); + expect(calledUrl).toContain('order=desc'); + }); + + it('omits pagination params when undefined', async () => { + const mockGet = vi.fn().mockResolvedValue({ points: [], summary: { timeframe: 'day', totalVolume: '0', totalFees: '0', transactionCount: 0, successRate: 0, averageLatencyMs: 0 } }); + const client = { get: mockGet } as unknown as HttpClient; + const resource = new AnalyticsResource(client); + + await resource.getMetrics({ asset: 'USDC' }); + + const calledUrl = mockGet.mock.calls[0]![0]; + expect(calledUrl).not.toContain('cursor='); + expect(calledUrl).not.toContain('limit='); + expect(calledUrl).not.toContain('order='); + }); + + it('serializes pagination params on getVolumeSummary', async () => { + const mockGet = vi.fn().mockResolvedValue({ timeframe: 'day', totalVolume: '50', totalFees: '0.5', transactionCount: 5, successRate: 0.8, averageLatencyMs: 200 }); + const client = { get: mockGet } as unknown as HttpClient; + const resource = new AnalyticsResource(client); + + await resource.getVolumeSummary({ timeframe: 'day', cursor: 'c2', limit: 10, order: 'asc' }); + + expect(mockGet).toHaveBeenCalledWith('/analytics/summary?timeframe=day&cursor=c2&limit=10&order=asc'); + }); }); diff --git a/packages/analytics/src/metrics.ts b/packages/analytics/src/metrics.ts index 11548cf..5b0f7a6 100644 --- a/packages/analytics/src/metrics.ts +++ b/packages/analytics/src/metrics.ts @@ -1,28 +1,37 @@ import type { HttpClient } from '@astroid/core'; -import type { - AnalyticsOverview, - AnalyticsQueryParams, - AnalyticsMetricsResponse, - VolumeSummary, -} from '@astroid/types'; +import type { AnalyticsQueryParams, AnalyticsMetricsResponse, PaginationParams, VolumeSummary } from '@astroid/types'; + +/** + * Serialize analytics query parameters — including pagination — into a + * URLSearchParams instance, skipping undefined values. + */ +function toSearchParams(params?: AnalyticsQueryParams): URLSearchParams { + const searchParams = new URLSearchParams(); + if (!params) return searchParams; + if (params.startDate) searchParams.set('startDate', params.startDate); + if (params.endDate) searchParams.set('endDate', params.endDate); + if (params.timeframe) searchParams.set('timeframe', params.timeframe); + if (params.asset) searchParams.set('asset', params.asset); + if (params.walletId) searchParams.set('walletId', params.walletId); + if (params.agentId) searchParams.set('agentId', params.agentId); + // Standard pagination arguments shared across resource packages. + if (params.cursor) searchParams.set('cursor', params.cursor); + if (params.limit !== undefined) searchParams.set('limit', String(params.limit)); + if (params.order) searchParams.set('order', params.order); + return searchParams; +} export class AnalyticsResource { constructor(private readonly client: HttpClient) {} /** * Fetch time-series analytics metrics matching the given parameters. + * + * Accepts optional {@link PaginationParams} (cursor / limit / order) for + * paging through large metric sets. */ async getMetrics(params?: AnalyticsQueryParams): Promise { - const searchParams = new URLSearchParams(); - if (params) { - if (params.startDate) searchParams.set('startDate', params.startDate); - if (params.endDate) searchParams.set('endDate', params.endDate); - if (params.timeframe) searchParams.set('timeframe', params.timeframe); - if (params.asset) searchParams.set('asset', params.asset); - if (params.walletId) searchParams.set('walletId', params.walletId); - if (params.agentId) searchParams.set('agentId', params.agentId); - } - + const searchParams = toSearchParams(params); const query = searchParams.toString(); const path = query ? `/analytics/metrics?${query}` : '/analytics/metrics'; const res = await this.client.get(path); @@ -31,18 +40,19 @@ export class AnalyticsResource { /** * Fetch summary statistics for a given timeframe or query. + * + * Accepts optional {@link PaginationParams} (cursor / limit / order) when a + * params object is supplied. */ async getVolumeSummary(timeframeOrParams?: string | AnalyticsQueryParams): Promise { const searchParams = new URLSearchParams(); if (typeof timeframeOrParams === 'string') { searchParams.set('timeframe', timeframeOrParams); - } else if (timeframeOrParams) { - if (timeframeOrParams.startDate) searchParams.set('startDate', timeframeOrParams.startDate); - if (timeframeOrParams.endDate) searchParams.set('endDate', timeframeOrParams.endDate); - if (timeframeOrParams.timeframe) searchParams.set('timeframe', timeframeOrParams.timeframe); - if (timeframeOrParams.asset) searchParams.set('asset', timeframeOrParams.asset); - if (timeframeOrParams.walletId) searchParams.set('walletId', timeframeOrParams.walletId); - if (timeframeOrParams.agentId) searchParams.set('agentId', timeframeOrParams.agentId); + } else { + const merged = toSearchParams(timeframeOrParams); + for (const [key, value] of merged.entries()) { + searchParams.set(key, value); + } } const query = searchParams.toString(); diff --git a/packages/budget/src/__tests__/budget.test.ts b/packages/budget/src/__tests__/budget.test.ts index 2478cca..0157c5b 100644 --- a/packages/budget/src/__tests__/budget.test.ts +++ b/packages/budget/src/__tests__/budget.test.ts @@ -88,6 +88,14 @@ describe('BudgetClient', () => { expect(http.get).toHaveBeenCalledWith('/v1/budgets/bud%2F1', {}); }); + it('getBudget() aliases get()', async () => { + const budget = makeBudget(); + http.get.mockResolvedValue(budget); + const result = await client.getBudget('bud_1'); + expect(http.get).toHaveBeenCalledWith('/v1/budgets/bud_1', {}); + expect(result).toBe(budget); + }); + it('list() forwards filters as a query', async () => { http.get.mockResolvedValue({ data: [makeBudget()] }); await client.list({ agentId: 'agt_1', enabled: true, limit: 50, cursor: undefined }); @@ -96,6 +104,58 @@ describe('BudgetClient', () => { }); }); + it('listBudgets() aliases list() with filters', async () => { + http.get.mockResolvedValue({ data: [makeBudget()] }); + await client.listBudgets({ agentId: 'agt_1', limit: 25 }); + expect(http.get).toHaveBeenCalledWith('/v1/budgets', { + query: { agentId: 'agt_1', limit: 25 }, + }); + }); + + it('simulateBudgetCheck() POSTs the spend to the simulate endpoint and reports allowed spends', async () => { + const result = { + budgetId: 'bud_1', + allowed: true, + wouldExceed: false, + afterRemaining: '750', + utilizationAfter: 0.25, + state: 'healthy', + violations: [], + explanation: 'Spend is within the budget limit.', + }; + http.post.mockResolvedValue(result); + const res = await client.simulateBudgetCheck('bud_1', { asset: 'USDC', amount: '250' }); + expect(http.post).toHaveBeenCalledWith('/v1/budgets/bud_1/simulate', { + asset: 'USDC', + amount: '250', + }); + expect(res).toBe(result); + expect(res.allowed).toBe(true); + }); + + it('simulateBudgetCheck() reports a limit breach', async () => { + const result = { + budgetId: 'bud_1', + allowed: false, + wouldExceed: true, + afterRemaining: '0', + utilizationAfter: 1, + state: 'exhausted', + violations: ['Spend would exceed the monthly budget limit of 1000.'], + explanation: 'Spend would exceed the monthly budget limit of 1000.', + }; + http.post.mockResolvedValue(result); + const res = await client.simulateBudgetCheck('bud_1', { asset: 'USDC', amount: '9999' }); + expect(http.post).toHaveBeenCalledWith('/v1/budgets/bud_1/simulate', { + asset: 'USDC', + amount: '9999', + }); + expect(res.allowed).toBe(false); + expect(res.wouldExceed).toBe(true); + expect(res.state).toBe('exhausted'); + expect(res.violations.length).toBeGreaterThan(0); + }); + it('update() PATCHes the budget', async () => { http.patch.mockResolvedValue(makeBudget({ name: 'Renamed' })); const result = await client.update('bud_1', { name: 'Renamed' }); diff --git a/packages/budget/src/budget.ts b/packages/budget/src/budget.ts index 3317121..d7d01c1 100644 --- a/packages/budget/src/budget.ts +++ b/packages/budget/src/budget.ts @@ -26,6 +26,8 @@ import type { BudgetHistoryEntry, BudgetHistoryQueryParams, BudgetMetrics, + BudgetSimulationRequest, + BudgetSimulationResult, ConsumeBudgetInput, CreateBudgetInput, DecimalString, @@ -231,11 +233,63 @@ export class BudgetClient { }); } + /** + * Alias of {@link get} that matches the SDK's `getBudget` resource naming. + * + * @example + * ```ts + * const budget = await budgets.getBudget('bud_1'); + * ``` + */ + async getBudget(budgetId: string, options?: { signal?: AbortSignal }): Promise { + return this.get(budgetId, options); + } + /** List budgets with optional filters and pagination. */ async list(params?: ListBudgetsParams): Promise> { return this.http.get>(BASE_PATH, { query: toBudgetQuery(params) }); } + /** + * Alias of {@link list} that matches the SDK's `listBudgets` resource naming. + * + * @example + * ```ts + * const page = await budgets.listBudgets({ agentId: 'agt_1', limit: 25 }); + * ``` + */ + async listBudgets(params?: ListBudgetsParams): Promise> { + return this.list(params); + } + + /** + * Simulate a prospective spend against a budget before executing it. + * + * The server evaluates the request against the budget's limit, active + * window and policy rules, returning whether the spend is allowed and the + * resulting headroom. + * + * @example + * ```ts + * const result = await budgets.simulateBudgetCheck('bud_1', { + * asset: 'USDC', + * amount: '250', + * }); + * if (!result.allowed) { + * throw new Error(result.explanation); + * } + * ``` + */ + async simulateBudgetCheck( + budgetId: string, + request: BudgetSimulationRequest, + ): Promise { + return this.http.post( + `${BASE_PATH}/${encodeURIComponent(budgetId)}/simulate`, + request, + ); + } + /** Update an existing budget. */ async update(budgetId: string, input: UpdateBudgetInput): Promise { return this.http.patch(`${BASE_PATH}/${encodeURIComponent(budgetId)}`, input); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d473556..84d70b8 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -63,9 +63,17 @@ export class Astroid { this.ai = {}; } - setAccessToken(accessToken: string | undefined): void { - this.httpClient.setAccessToken(accessToken); - } + public readonly wallets = { + get: async (id: string, options?: { query?: QueryParams }) => { + return this.request<{ data: any }>({ method: 'GET', path: `/wallets/${id}`, query: options?.query }); + }, + list: async (options?: { query?: QueryParams }) => { + return this.request<{ data: any[] }>({ method: 'GET', path: '/wallets', query: options?.query }); + }, + balance: async (id: string, options?: { query?: QueryParams }) => { + return this.request<{ data: any }>({ method: 'GET', path: `/wallets/${id}/balance`, query: options?.query }); + }, + }; register(plugin: ClientPlugin): this { this.plugins.push(plugin); diff --git a/packages/react/src/__tests__/hooks.test.tsx b/packages/react/src/__tests__/hooks.test.tsx index 4c38e20..2bc28e7 100644 --- a/packages/react/src/__tests__/hooks.test.tsx +++ b/packages/react/src/__tests__/hooks.test.tsx @@ -17,6 +17,7 @@ import { useDeleteAgent, useSimulatePolicy, useWallets, + useWalletBalance, queryKeys, useAstroid, type AstroidProviderProps, @@ -76,6 +77,11 @@ describe('queryKeys', () => { expect(key).toEqual(['astroid', 'wallets', 'detail', 'wal_abc']); }); + it('wallets.balance produces a key containing the id', () => { + const key = queryKeys.wallets.balance('wal_abc'); + expect(key).toEqual(['astroid', 'wallets', 'balance', 'wal_abc']); + }); + it('agents.list and wallets.list produce different keys', () => { expect(queryKeys.agents.list()).not.toEqual(queryKeys.wallets.list()); }); @@ -131,6 +137,36 @@ describe('useWallets', () => { }); }); +describe('useWalletBalance', () => { + it('returns a loading state initially', () => { + let isLoading = false; + + function TestComponent() { + const query = useWalletBalance('wal_1'); + isLoading = query.isLoading; + return null; + } + + const { unmount } = renderInProviders(createElement(TestComponent)); + expect(typeof isLoading).toBe('boolean'); + unmount(); + }); + + it('query is disabled when walletId is undefined', () => { + let isFetching = true; + + function TestComponent() { + const query = useWalletBalance(undefined); + isFetching = query.isFetching; + return null; + } + + const { unmount } = renderInProviders(createElement(TestComponent)); + expect(isFetching).toBe(false); + unmount(); + }); +}); + describe('useAgents', () => { it('returns a loading state initially', () => { let isLoading = false; diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts index 0b45a5f..07def41 100644 --- a/packages/react/src/hooks.ts +++ b/packages/react/src/hooks.ts @@ -2,13 +2,7 @@ import { useContext } from 'react'; import { useMutation, useQuery, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'; import { AstroidClientContext } from './provider.js'; import type { Astroid } from '@astroid/client'; -import type { - Agent, - Paginated, - PaginationParams, - PolicySimulationRequest, - PolicySimulationResult, -} from '@astroid/types'; +import type { Agent, Paginated, PaginationParams, PolicySimulationRequest, PolicySimulationResult, Wallet, WalletBalance } from '@astroid/types'; export { useCreateAgent, useUpdateAgent, useDeleteAgent } from './hooks/useAgents.js'; export { @@ -53,7 +47,7 @@ export const queryKeys = { all: ['astroid', 'wallets'] as const, list: (params?: PaginationParams) => ['astroid', 'wallets', 'list', params ?? {}] as const, detail: (id: string) => ['astroid', 'wallets', 'detail', id] as const, - balance: (id: string) => ['astroid', 'wallets', 'detail', id, 'balance'] as const, + balance: (id: string) => ['astroid', 'wallets', 'balance', id] as const, }, agents: { all: ['astroid', 'agents'] as const, @@ -65,6 +59,61 @@ export const queryKeys = { }, } as const; +/** + * Fetch a paginated list of wallets. + */ +export function useWallets(params?: PaginationParams): UseQueryResult, Error> { + const astroid = useAstroidClient(); + return useQuery({ + queryKey: queryKeys.wallets.list(params), + queryFn: () => astroid.wallets.list(params), + }); +} + +/** + * Fetch a single wallet by ID. + */ +export function useWallet(id: string | undefined): UseQueryResult { + const astroid = useAstroidClient(); + return useQuery({ + queryKey: queryKeys.wallets.detail(id ?? ''), + queryFn: () => astroid.wallets.get(id!), + enabled: Boolean(id), + }); +} + +/** Options for {@link useWalletBalance}. */ +export interface UseWalletBalanceOptions { + /** Poll interval in ms. Defaults to `false` (no polling). */ + pollingInterval?: number; + /** Stale-time in ms. Defaults to 30_000. */ + staleTime?: number; + /** Whether the query should run at all. Defaults to `true`. */ + enabled?: boolean; +} + +/** + * Fetch the live on-chain balance of a wallet via TanStack Query. + * + * The query is cached under `['astroid', 'wallets', 'balance', id]`, keeps the + * balance fresh for 30s by default, and supports polling for near real-time + * subscriptions. + */ +export function useWalletBalance( + walletId: string | undefined, + options: UseWalletBalanceOptions = {}, +): UseQueryResult { + const astroid = useAstroidClient(); + const { pollingInterval, staleTime = 30_000, enabled = true } = options; + return useQuery({ + queryKey: queryKeys.wallets.balance(walletId ?? ''), + queryFn: () => astroid.wallets.balance(walletId!), + enabled: Boolean(walletId) && enabled, + staleTime, + refetchInterval: pollingInterval && pollingInterval > 0 ? pollingInterval : false, + }); +} + /** * Fetch a paginated list of agents. */ diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b723b18..c7af52a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,583 +1,18 @@ -/** - * `@astroid/react` — React bindings for the Astroid SDK. - * - * Wrap your tree in {@link AstroidProvider}, then reach the client from any - * component with {@link useAstroid}. The read hooks are thin, correctly-keyed - * wrappers over TanStack Query v5; the mutation hooks invalidate the relevant - * queries on success so lists stay fresh without manual bookkeeping. - * - * ```tsx - * import { AstroidProvider, useWallets } from '@astroid/react'; - * - * function App() { - * return ( - * - * - * - * ); - * } - * - * function Wallets() { - * const { data, isLoading } = useWallets(); - * if (isLoading) return

Loading…

; - * return
    {data?.data.map((w) =>
  • {w.name}
  • )}
; - * } - * ``` - * - * The `"use client"` directive is prepended to the built output by tsup, so this - * module is safe to import from a React Server Components tree. - * - * @packageDocumentation - */ - -import { - createContext, - createElement, - useContext, - useEffect, - useMemo, - useRef, - type ReactNode, -} from 'react'; -import { - useMutation, - useQuery, - useQueryClient, - type UseMutationOptions, - type UseMutationResult, - type UseQueryOptions, - type UseQueryResult, -} from '@tanstack/react-query'; -import { - Astroid, - type AgentListParams, - type BudgetListParams, - type PolicyListParams, - type WalletListParams, -} from '@astroid/client'; -import type { - Agent, - AnalyticsOverview, - AnalyticsQuery, - Budget, - CreateAgentInput, - CreateWalletInput, - Notification, - NotificationListParams, - Paginated, - PaymentIntent, - PaymentIntentResult, - Policy, - Transaction, - TransactionListParams, - TransferInput, - Wallet, - WalletBalance, - WebhookEventEnvelope, - WebhookEventName, -} from '@astroid/types'; - -/* -------------------------------------------------------------------------- */ -/* provider */ -/* -------------------------------------------------------------------------- */ - -const AstroidContext = createContext(null); - -/** Props for {@link AstroidProvider}: supply a ready client or a config to build one. */ -export type AstroidProviderProps = { - children: ReactNode; -} & ( - | { client: Astroid; config?: never } - | { config: ConstructorParameters[0]; client?: never } -); - -/** - * Provides an {@link Astroid} client to the tree. Pass either an existing - * `client` (recommended if you construct it elsewhere) or a `config` object - * from which one is memoized. Assumes a TanStack Query `QueryClientProvider` - * is present higher in the tree. - */ -export function AstroidProvider(props: AstroidProviderProps): ReactNode { - const { children } = props; - const client = useMemo( - () => ('client' in props && props.client ? props.client : new Astroid(props.config)), - // Rebuild only when the identity of the passed client/config changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - ['client' in props ? props.client : props.config], - ); - return createElement(AstroidContext.Provider, { value: client }, children); -} - -/** Access the {@link Astroid} client from context. Throws if no provider is present. */ -export function useAstroid(): Astroid { - const client = useContext(AstroidContext); - if (!client) { - throw new Error('useAstroid must be used within an .'); - } - return client; -} - -/* -------------------------------------------------------------------------- */ -/* query key factory */ -/* -------------------------------------------------------------------------- */ - -/** - * Canonical, stable query keys. Every read hook derives its key from here so - * mutations can invalidate precisely (e.g. `queryKeys.wallets.all`). - */ -export const queryKeys = { - wallets: { - all: ['astroid', 'wallets'] as const, - list: (params?: WalletListParams) => ['astroid', 'wallets', 'list', params ?? {}] as const, - detail: (id: string) => ['astroid', 'wallets', 'detail', id] as const, - balance: (id: string) => ['astroid', 'wallets', 'balance', id] as const, - }, - agents: { - all: ['astroid', 'agents'] as const, - list: (params?: AgentListParams) => ['astroid', 'agents', 'list', params ?? {}] as const, - detail: (id: string) => ['astroid', 'agents', 'detail', id] as const, - }, - policies: { - all: ['astroid', 'policies'] as const, - list: (params?: PolicyListParams) => ['astroid', 'policies', 'list', params ?? {}] as const, - detail: (id: string) => ['astroid', 'policies', 'detail', id] as const, - }, - budgets: { - all: ['astroid', 'budgets'] as const, - list: (params?: BudgetListParams) => ['astroid', 'budgets', 'list', params ?? {}] as const, - detail: (id: string) => ['astroid', 'budgets', 'detail', id] as const, - }, - transactions: { - all: ['astroid', 'transactions'] as const, - list: (params?: TransactionListParams) => - ['astroid', 'transactions', 'list', params ?? {}] as const, - detail: (id: string) => ['astroid', 'transactions', 'detail', id] as const, - }, - notifications: { - all: ['astroid', 'notifications'] as const, - list: (params?: NotificationListParams) => - ['astroid', 'notifications', 'list', params ?? {}] as const, - unreadCount: ['astroid', 'notifications', 'unread-count'] as const, - }, - analytics: { - overview: (query?: AnalyticsQuery) => ['astroid', 'analytics', 'overview', query ?? {}] as const, - }, -} as const; - -/** - * Options a caller may pass to a read hook (wrapper over TanStack `UseQueryOptions`). - * - * Supports full TanStack overrides including `queryKey`, `staleTime`, - * `refetchInterval`, `gcTime`, `select`, `enabled`, etc. When `queryKey` is - * supplied it replaces the hook's default `queryKeys.*` value, enabling custom - * caching strategies and integration with global state managers. - * - * @typeParam TData Data returned by the query. - * @typeParam TError Error type (defaults to `Error`). - */ -export type ReadOptions = Omit< - UseQueryOptions, - 'queryKey' | 'queryFn' -> & { - /** Override the default query key for custom caching / global state sync. */ - queryKey?: readonly unknown[]; -}; - -/* -------------------------------------------------------------------------- */ -/* read hooks */ -/* -------------------------------------------------------------------------- */ - -/** - * List wallets. - * - * Supports custom query options such as `queryKey`, `staleTime`, - * `refetchInterval`, `gcTime`, and `select` for advanced caching strategies. - * - * @param params Pagination/filter params forwarded to `astroid.wallets.list`. - * @param options Custom TanStack Query options including `queryKey` override, - * `staleTime`, `refetchInterval`, `gcTime`, `select`, etc. - * When `queryKey` is supplied it replaces the default - * `queryKeys.wallets.list(params)` key, enabling integration - * with global state managers. - */ -export function useWallets( - params?: WalletListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.wallets.list(params), - queryFn: () => astroid.wallets.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * Fetch a single wallet. Disabled until `id` is truthy. - * - * `options.enabled` is merged with the internal `Boolean(id)` guard so a - * custom `enabled: false` still disables the query even when `id` is present. - * - * @param id Wallet id (when falsy, the query is disabled regardless of options). - * @param options Custom query options including `queryKey` override, `staleTime`, - * `refetchInterval`, `gcTime`, `select`, and `enabled`. - */ -export function useWallet( - id: string | undefined, - options?: ReadOptions, -): UseQueryResult { - const astroid = useAstroid(); - const { queryKey, enabled: optionEnabled, ...rest } = options ?? {}; - const enabled = Boolean(id) && (optionEnabled ?? true); - return useQuery({ - queryKey: queryKey ?? queryKeys.wallets.detail(id ?? ''), - queryFn: () => astroid.wallets.get(id as string), - enabled, - ...(rest as Omit, 'queryKey' | 'queryFn' | 'enabled'>), - }); -} - -/** - * Fetch the live on-chain balance of a single wallet, with a sensible - * stale-time default so balances stay fresh without hammering the API. - * - * The query is automatically **disabled** when `walletId` is `undefined` or - * empty. Balances are inherently volatile, so by default the result is marked - * stale after 15s; pass `options.staleTime` to tune, or set - * `refetchInterval` (e.g. `30_000`) to poll while mounted. - * - * @param walletId The wallet to read balances for. Pass `undefined` to skip. - * @param options Extra TanStack Query options (`enabled`, `staleTime`, - * `refetchInterval`, etc.). The `queryKey`/`queryFn` are set - * internally and cannot be overridden. - * @returns A TanStack Query result with `data` (a {@link WalletBalance}), - * `isLoading`, `isError`, `error`, etc. - * - * @example - * ```tsx - * const { data: balance, isStale } = useWalletBalance(activeWalletId, { - * refetchInterval: 30_000, - * }); - * ``` - */ -export function useWalletBalance( - walletId: string | undefined, - options?: ReadOptions, -): UseQueryResult { - const astroid = useAstroid(); - return useQuery({ - queryKey: queryKeys.wallets.balance(walletId ?? ''), - queryFn: () => astroid.wallets.balance(walletId as string), - enabled: Boolean(walletId) && options?.enabled !== false, - staleTime: 15_000, // balances go stale quickly; refresh on refocus/interval - ...options, - }); -} - -/** - * Fetch a paginated list of AI agents. - * - * @param params Optional filters: `page`, `pageSize`, `walletId`, etc. - * @param options Extra TanStack Query options. - * @returns A TanStack Query result with `data` (a {@link Paginated} of - * {@link Agent}), `isLoading`, `isError`, `error`, etc. - * - * @example - * ```tsx - * const { data } = useAgents({ walletId: 'wal_123' }); - * ``` - */ -export function useAgents( - params?: AgentListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.agents.list(params), - queryFn: () => astroid.agents.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * Fetch a single agent. Disabled until `id` is truthy. - * @param id Agent id. - * @param options Custom query options (`queryKey` override, `staleTime`, `refetchInterval`, etc.). - */ -export function useAgent( - id: string | undefined, - options?: ReadOptions, -): UseQueryResult { - const astroid = useAstroid(); - const { queryKey, enabled: optionEnabled, ...rest } = options ?? {}; - const enabled = Boolean(id) && (optionEnabled ?? true); - return useQuery({ - queryKey: queryKey ?? queryKeys.agents.detail(id ?? ''), - queryFn: () => astroid.agents.get(id as string), - enabled, - ...(rest as Omit, 'queryKey' | 'queryFn' | 'enabled'>), - }); -} - -/** - * List policies. - * @param params Filter params. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function usePolicies( - params?: PolicyListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.policies.list(params), - queryFn: () => astroid.policies.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * List budgets. - * @param params Filter params. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function useBudgets( - params?: BudgetListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.budgets.list(params), - queryFn: () => astroid.budgets.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * List transactions. - * @param params Filter params. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function useTransactions( - params?: TransactionListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.transactions.list(params), - queryFn: () => astroid.transactions.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * List notifications. - * @param params Filter params. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function useNotifications( - params?: NotificationListParams, - options?: ReadOptions>, -): UseQueryResult, Error> { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.notifications.list(params), - queryFn: () => astroid.notifications.list(params), - ...(rest as Omit, Error, Paginated, readonly unknown[]>, 'queryKey' | 'queryFn'>), - }); -} - -/** - * The count of unread notifications. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function useUnreadCount( - options?: ReadOptions, -): UseQueryResult { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.notifications.unreadCount, - queryFn: () => astroid.notifications.unreadCount(), - ...(rest as Omit, 'queryKey' | 'queryFn'>), - }); -} - -/** - * Headline analytics for the dashboard. - * @param query Analytics filters. - * @param options Custom query options (`queryKey`, `staleTime`, `refetchInterval`, etc.). - */ -export function useAnalyticsOverview( - query?: AnalyticsQuery, - options?: ReadOptions, -): UseQueryResult { - const astroid = useAstroid(); - const { queryKey, ...rest } = options ?? {}; - return useQuery({ - queryKey: queryKey ?? queryKeys.analytics.overview(query), - queryFn: () => astroid.analytics.overview(query), - ...(rest as Omit, 'queryKey' | 'queryFn'>), - }); -} - -/* -------------------------------------------------------------------------- */ -/* mutation hooks */ -/* -------------------------------------------------------------------------- */ - -/** - * Options a caller may pass to a mutation hook (wrapping `UseMutationOptions`). - * - * Supports standard overrides: `mutationKey`, `onSuccess`, `onError`, - * `onSettled`, `retry`, `gcTime`, etc. The hook's automatic cache - * invalidation is composed with any user-provided callbacks so both run. - * - * @typeParam TData Result data type. - * @typeParam TVars Variable type passed to the mutation. - * @typeParam TError Error type (defaults to `Error`). - */ -export type WriteOptions = Omit< - UseMutationOptions, - 'mutationFn' ->; - -/** - * Create a wallet; invalidates the wallet lists on success. - * - * Supports full mutation option overrides: `mutationKey`, `onSuccess`, - * `onError`, `onSettled`, `retry`, etc. User callbacks are composed with - * the automatic invalidation so both run. - * - * @param options Custom mutation options including `onSuccess`, `onError`, - * `onSettled`, `mutationKey`, `retry`, etc. - */ -export function useCreateWallet( - options?: WriteOptions, -): UseMutationResult { - const astroid = useAstroid(); - const qc = useQueryClient(); - const { onSuccess, onError, onSettled, ...rest } = options ?? {}; - return useMutation({ - mutationFn: (input: CreateWalletInput) => astroid.wallets.create(input), - ...(rest as Omit, 'mutationFn'>), - onSuccess: (data, vars, ctx) => { - void qc.invalidateQueries({ queryKey: queryKeys.wallets.all }); - onSuccess?.(data, vars, ctx as unknown as void); - }, - onError: (err, vars, ctx) => onError?.(err, vars, ctx as unknown as void), - onSettled: (data, err, vars, ctx) => onSettled?.(data, err, vars, ctx as unknown as void), - }); -} - -/** - * Transfer from a wallet; invalidates wallets and transactions on success. - * - * @param walletId Source wallet id. - * @param options Custom mutation options (`onSuccess`, `onError`, `onSettled`, - * `mutationKey`, `retry`, etc.). - */ -export function useTransfer( - walletId: string, - options?: WriteOptions, -): UseMutationResult { - const astroid = useAstroid(); - const qc = useQueryClient(); - const { onSuccess, onError, onSettled, ...rest } = options ?? {}; - return useMutation({ - mutationFn: (input: TransferInput) => astroid.wallets.transfer(walletId, input), - ...(rest as Omit, 'mutationFn'>), - onSuccess: (data, vars, ctx) => { - void qc.invalidateQueries({ queryKey: queryKeys.wallets.all }); - void qc.invalidateQueries({ queryKey: queryKeys.transactions.all }); - onSuccess?.(data, vars, ctx as unknown as void); - }, - onError: (err, vars, ctx) => onError?.(err, vars, ctx as unknown as void), - onSettled: (data, err, vars, ctx) => onSettled?.(data, err, vars, ctx as unknown as void), - }); -} - -/** - * Create an agent; invalidates the agent lists on success. - * @param options Custom mutation options (`onSuccess`, `onError`, `onSettled`, etc.). - */ -export function useCreateAgent( - options?: WriteOptions, -): UseMutationResult { - const astroid = useAstroid(); - const qc = useQueryClient(); - const { onSuccess, onError, onSettled, ...rest } = options ?? {}; - return useMutation({ - mutationFn: (input: CreateAgentInput) => astroid.agents.create(input), - ...(rest as Omit, 'mutationFn'>), - onSuccess: (data, vars, ctx) => { - void qc.invalidateQueries({ queryKey: queryKeys.agents.all }); - onSuccess?.(data, vars, ctx as unknown as void); - }, - onError: (err, vars, ctx) => onError?.(err, vars, ctx as unknown as void), - onSettled: (data, err, vars, ctx) => onSettled?.(data, err, vars, ctx as unknown as void), - }); -} - -/** - * The AI-native mutation: submit a financial intent. On an executed or pending - * outcome it invalidates transactions and wallets so balances reflect the draw. - * - * @param options Custom mutation options (`onSuccess`, `onError`, `onSettled`, - * `mutationKey`, `retry`, etc.). - */ -export function useRequestPayment( - options?: WriteOptions, -): UseMutationResult { - const astroid = useAstroid(); - const qc = useQueryClient(); - const { onSuccess, onError, onSettled, ...rest } = options ?? {}; - return useMutation({ - mutationFn: (intent: PaymentIntent) => astroid.ai.requestPayment(intent), - ...(rest as Omit, 'mutationFn'>), - onSuccess: (data, vars, ctx) => { - if (data.outcome === 'executed' || data.outcome === 'pending_approval') { - void qc.invalidateQueries({ queryKey: queryKeys.transactions.all }); - void qc.invalidateQueries({ queryKey: queryKeys.wallets.all }); - } - onSuccess?.(data, vars, ctx as unknown as void); - }, - onError: (err, vars, ctx) => onError?.(err, vars, ctx as unknown as void), - onSettled: (data, err, vars, ctx) => onSettled?.(data, err, vars, ctx as unknown as void), - }); -} - -/* -------------------------------------------------------------------------- */ -/* event bridge */ -/* -------------------------------------------------------------------------- */ - -/** - * Subscribe a component to a client event for its lifetime. The handler is kept - * in a ref, so passing a fresh closure each render does not re-subscribe. - * - * ```tsx - * useAstroidEvent('transaction.completed', (tx) => toast(`Sent ${tx.id}`)); - * ``` - */ -export function useAstroidEvent( - event: K, - handler: (data: WebhookEventEnvelope['data'], envelope: WebhookEventEnvelope) => void, -): void { - const astroid = useAstroid(); - const handlerRef = useRef(handler); - handlerRef.current = handler; - - useEffect(() => { - const off = astroid.on(event, ((data: unknown, envelope: unknown) => { - (handlerRef.current as (d: unknown, e: unknown) => void)(data, envelope); - }) as never); - return off; - }, [astroid, event]); -} - -export { Astroid } from '@astroid/client'; +export { AstroidProvider, type AstroidProviderProps } from './provider.js'; +export { + useAstroid, + useAstroidClient, + queryKeys, + useWallets, + useWallet, + useWalletBalance, + useAgents, + useAgent, + useCreateAgent, + useUpdateAgent, + useDeleteAgent, + useSimulatePolicy, +} from './hooks.js'; +export { useAgentLogs, agentLogKeys, type UseAgentLogsOptions } from './hooks/useAgentLogs.js'; +export { useAgentStatus, agentStatusKeys, type UseAgentStatusOptions } from './hooks/useAgentStatus.js'; +export { useAgentMetrics, type UseAgentMetricsOptions, type AgentMetricsData, type UseAgentMetricsResult } from './hooks/useAgentMetrics.js'; diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index 04b3df5..6b25e29 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -17,6 +17,7 @@ import type { Agent } from './entities.js'; import { AgentRole, AgentStatus } from './enums.js'; import type { IsoDateTime } from './entities.js'; +import type { PaginationParams } from './common.js'; export { AgentRole, AgentStatus } from './enums.js'; export type { Agent } from './entities.js'; @@ -235,3 +236,85 @@ export function normalizeCreateAgentDto(input: CreateAgentDto): CreateAgentDto { /** Type-only marker retained for documentation of the timestamp format. */ export type AgentTimestamp = IsoDateTime; + +/* -------------------------------------------------------------------------- */ +/* Lifecycle events */ +/* -------------------------------------------------------------------------- */ + +/** + * Lifecycle events an autonomous agent can emit. + * + * The API exposes these as an event stream; clients subscribe to receive + * push-style notifications and can page through historical events. + */ +export const AgentLifecycleEventType = { + CREATED: 'agent.created', + SUSPENDED: 'agent.suspended', + RESUMED: 'agent.resumed', + BUDGET_EXHAUSTED: 'agent.budget_exhausted', +} as const; +export type AgentLifecycleEventType = + (typeof AgentLifecycleEventType)[keyof typeof AgentLifecycleEventType]; + +/** Every valid {@link AgentLifecycleEventType} value, as a readonly tuple. */ +export const AGENT_LIFECYCLE_EVENT_TYPE_VALUES = Object.freeze( + Object.values(AgentLifecycleEventType), +) as readonly AgentLifecycleEventType[]; + +/** Structured payload attached to an agent lifecycle event. */ +export interface AgentLifecycleEventPayload { + /** The agent the event belongs to. */ + agentId: string; + /** The organization that owns the agent. */ + organizationId: string; + /** ISO-8601 instant the event occurred. */ + occurredAt: IsoDateTime; + /** Free-form details, e.g. the exhausted budget for `agent.budget_exhausted`. */ + details?: Record; +} + +/** A single agent lifecycle event, as returned by the event endpoints. */ +export interface AgentLifecycleEvent { + id: string; + type: AgentLifecycleEventType; + agentId: string; + organizationId: string; + occurredAt: IsoDateTime; + payload: AgentLifecycleEventPayload; +} + +/** Filter + pagination parameters for listing agent lifecycle events. */ +export interface ListAgentEventsParams extends PaginationParams { + /** Only events of these types. */ + eventTypes?: AgentLifecycleEventType[]; + /** Only events at or after this instant (ISO-8601). */ + from?: IsoDateTime; + /** Only events at or before this instant (ISO-8601). */ + to?: IsoDateTime; +} + +/** + * Options for creating an agent lifecycle event subscription. + * + * All fields are optional; the backend defaults to delivering every lifecycle + * event type with no history replay. + */ +export interface AgentEventSubscriptionOptions { + /** Only these event types are delivered. When omitted, all are delivered. */ + eventTypes?: AgentLifecycleEventType[]; + /** Replay events starting from this cursor. */ + cursor?: string; + /** Whether to include historical events from before the subscription existed. */ + includeHistory?: boolean; +} + +/** A subscription to an agent's lifecycle event stream. */ +export interface AgentEventSubscription { + id: string; + agentId: string; + organizationId: string; + eventTypes: AgentLifecycleEventType[]; + includeHistory: boolean; + status: 'ACTIVE' | 'PAUSED'; + createdAt: IsoDateTime; +} diff --git a/packages/types/src/analytics.ts b/packages/types/src/analytics.ts index 2c38eae..3bb23e8 100644 --- a/packages/types/src/analytics.ts +++ b/packages/types/src/analytics.ts @@ -1,13 +1,13 @@ -export type Timeframe = 'hour' | 'day' | 'week' | 'month' | 'year' | string; +import type { PaginationParams } from './common.js'; -import type { DecimalString } from './entities.js'; -import type { RiskBand } from './enums.js'; -import type { Paginated, PaginationParams } from './common.js'; +export type Timeframe = 'hour' | 'day' | 'week' | 'month' | 'year' | string; -/** Query parameters accepted by analytics endpoints. */ -export interface AnalyticsQuery { - from?: string; - to?: string; +export interface AnalyticsQueryParams extends PaginationParams { + startDate?: string; + endDate?: string; + timeframe?: Timeframe; + asset?: string; + walletId?: string; agentId?: string; } diff --git a/packages/types/src/budget.ts b/packages/types/src/budget.ts index 8bf0a79..de4bb96 100644 --- a/packages/types/src/budget.ts +++ b/packages/types/src/budget.ts @@ -51,4 +51,137 @@ export interface BudgetUtilization { remaining: DecimalString; /** Fraction consumed (0..1+), useful for progress bars. */ utilization: number; -} \ No newline at end of file + /** {@link utilization} as a percentage, `0`–`100`, rounded to 2 dp. */ + percent: number; + /** Bucketed health derived from {@link percent} and the configured thresholds. */ + state: BudgetAllocationState; + /** Whether a prospective spend (when supplied) would push spending past the limit. */ + wouldExceed?: boolean; +} + +/** Thresholds (percent of limit) that bucket an allocation into a {@link BudgetAllocationState}. */ +export interface BudgetAllocationThresholds { + /** Percent at which the state becomes `warning`. Default `80`. */ + warnAt?: number; + /** Percent at which the state becomes `critical`. Default `95`. */ + criticalAt?: number; +} + +/* -------------------------------------------------------------------------- */ +/* Simulation */ +/* -------------------------------------------------------------------------- */ + +/** A prospective spend to simulate against a budget. */ +export interface BudgetSimulationRequest { + /** Asset identifier (e.g. `"USDC"`, `"XLM"`). */ + asset: string; + /** Amount to spend (decimal string or number). */ + amount: DecimalString | number; + /** Optional agent the spend is attributed to. */ + agentId?: string; + /** Optional originating transaction. */ + transactionId?: string; +} + +/** The outcome of simulating a spend against a budget. */ +export interface BudgetSimulationResult { + budgetId: string; + /** Whether the spend is allowed under the budget's limits. */ + allowed: boolean; + /** Whether the spend would push the budget past its limit. */ + wouldExceed: boolean; + /** Remaining headroom after the simulated spend. */ + afterRemaining: DecimalString; + /** Utilization fraction after the simulated spend, `0`–`1`. */ + utilizationAfter: number; + /** Bucketed health after the simulated spend. */ + state: BudgetAllocationState; + /** Violated rules (empty when `allowed` is true). */ + violations: string[]; + /** Human-readable explanation of the outcome. */ + explanation: string; +} + +/* -------------------------------------------------------------------------- */ +/* Threshold alerts */ +/* -------------------------------------------------------------------------- */ + +/** Delivery channel for a budget threshold alert. */ +export type BudgetAlertChannel = 'EMAIL' | 'WEBHOOK' | 'SLACK' | 'DASHBOARD'; + +/** Lifecycle status of a budget alert subscription. */ +export type BudgetAlertStatus = 'ACTIVE' | 'PAUSED' | 'TRIGGERED'; + +/** The utilization percentages Astroid recommends configuring alerts at. */ +export const BUDGET_ALERT_THRESHOLDS = Object.freeze([50, 80, 100] as const); + +/** A configured budget threshold alert subscription. */ +export interface BudgetAlert { + id: string; + budgetId: string; + organizationId: string; + /** Utilization percentage (`1`–`1000`) at which the alert fires. */ + thresholdPercent: number; + /** Where the notification is delivered. */ + channel: BudgetAlertChannel; + /** + * Channel-specific destination: a URL for `WEBHOOK`, an email address for + * `EMAIL`, a channel id for `SLACK`. Ignored for `DASHBOARD`. + */ + target: string; + /** Current status. */ + status: BudgetAlertStatus; + /** Whether the alert re-arms after the budget period resets. */ + recurring: boolean; + /** Last time this alert fired, if ever. */ + lastTriggeredAt?: IsoDateTime | null; + createdAt: IsoDateTime; + updatedAt: IsoDateTime; +} + +/** Payload for creating a budget threshold alert. */ +export interface CreateBudgetAlertInput { + /** Utilization percentage at which to fire (e.g. `50`, `80`, `100`). */ + thresholdPercent: number; + /** Delivery channel. */ + channel: BudgetAlertChannel; + /** Channel-specific destination. Required for every channel except `DASHBOARD`. */ + target?: string; + /** Whether the alert re-arms each budget period. Defaults to `true` server-side. */ + recurring?: boolean; +} + +/** Payload for updating a budget threshold alert (all fields optional). */ +export interface UpdateBudgetAlertInput { + thresholdPercent?: number; + channel?: BudgetAlertChannel; + target?: string; + recurring?: boolean; + status?: Extract; +} + +/** Filter + pagination parameters for listing budget alerts. */ +export interface ListBudgetAlertsParams extends PaginationParams { + /** Only alerts in this status. */ + status?: BudgetAlertStatus; + /** Only alerts on this channel. */ + channel?: BudgetAlertChannel; +} + +/* -------------------------------------------------------------------------- */ +/* Budget history queries */ +/* -------------------------------------------------------------------------- */ + +/** Filter + pagination parameters for a budget's consumption history. */ +export interface BudgetHistoryQueryParams extends PaginationParams { + /** Only entries created at or after this instant (ISO-8601). */ + from?: IsoDateTime; + /** Only entries created at or before this instant (ISO-8601). */ + to?: IsoDateTime; + /** Only entries linked to this transaction. */ + transactionId?: string; + /** Only entries whose `amount` is at least this value. */ + minAmount?: DecimalString | number; + /** Only entries whose `amount` is at most this value. */ + maxAmount?: DecimalString | number; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index bcaef64..05826f5 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -6,5 +6,32 @@ export * from './policy.js'; export * from './analytics.js'; export * from './agent-events.js'; export * from './webhooks.js'; -export * from './ai.js'; -export * from './schemas.js'; + +// Agent resource DTOs and helpers. `AgentEntity`/`Agent`, `AgentStatus` and +// `AgentRole` originate in `./entities.js` and `./enums.js`, so re-export only +// the members `agent.ts` adds to avoid duplicate-export ambiguity. +export { + type AgentEntity, + type AgentMetadata, + type AgentInitialBudget, + type CreateAgentDto, + type UpdateAgentDto, + type CreateAgentParams, + type UpdateAgentParams, + type ListAgentsParams, + type AgentTimestamp, + AGENT_LIFECYCLE_EVENT_TYPE_VALUES, + type AgentLifecycleEventType, + type AgentLifecycleEventPayload, + type AgentLifecycleEvent, + type ListAgentEventsParams, + type AgentEventSubscriptionOptions, + type AgentEventSubscription, + AGENT_STATUS_VALUES, + AGENT_ROLE_VALUES, + isAgentStatus, + isAgentRole, + isAgentEntity, + parseAgentEntity, + normalizeCreateAgentDto, +} from './agent.js';