diff --git a/admin/slices/agent/agent/data/agent.gateway.ts b/admin/slices/agent/agent/data/agent.gateway.ts new file mode 100644 index 00000000..1a078813 --- /dev/null +++ b/admin/slices/agent/agent/data/agent.gateway.ts @@ -0,0 +1,185 @@ +import { AgentsService, LogsService } from '#api/data'; +import { client } from '#api/data/repositories/api/client.gen'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IAgentGateway } from '../domain/agent.gateway'; +import type { + IAgentData, + IAgentEnvVar, + IAgentMetrics, + ICreateAgentData, + IUpdateAgentData, +} from '../domain/agent.types'; +import { AgentMapper } from './agent.mapper'; + +interface HeyApiResult { + data?: unknown; + error?: unknown; + response?: { status?: number }; +} + +/** + * Hey API's axios client never throws by default — it returns + * `{ data, error, response }`, and on a network-level failure (no HTTP + * response: CORS rejection, API down) it sets `error` to an empty `{}` that is + * truthy but has no `.message`. Turn every failure mode into a meaningful Error + * and return the unwrapped payload on success. + */ +function unwrapOrThrow(res: HeyApiResult, action: string): unknown { + const status = res.response?.status; + const err = res.error; + if (err !== undefined && err !== null) { + const message = (err as { message?: string }).message; + if (message) throw new Error(`${action} failed: ${message}`); + if (status && status >= 400) throw new Error(`${action} failed: HTTP ${status}`); + throw new Error( + `${action} failed: could not reach the API. Check that the API is ` + + "running and that this app's origin is listed in CORS_ORIGIN.", + ); + } + const payload = unwrapEnvelope(res.data); + if (payload === null) throw new Error(`${action} failed: the API returned no data.`); + return payload; +} + +function readAccessToken(): string | null { + if (typeof document === 'undefined') return null; + const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/); + return m ? decodeURIComponent(m[1]) : null; +} + +export class AgentGateway extends BaseGateway implements IAgentGateway { + private mapper = new AgentMapper(); + + findAll(): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindAll(); + return this.mapper.toList(unwrapEnvelope(res.data)); + }); + } + + findById(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindById({ path: { id } }); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } + + findAdmin(): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerFindAdmin(); + return this.mapper.toEntity(unwrapEnvelope(res.data)); + }); + } + + create(input: ICreateAgentData): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerCreate({ + body: this.mapper.toCreateDto(input), + }); + return this.entityOrThrow(unwrapOrThrow(res, 'Agent create'), 'Agent create'); + }); + } + + update(id: string, input: IUpdateAgentData): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerUpdate({ + path: { id }, + body: this.mapper.toUpdateDto(input), + }); + return this.entityOrThrow(unwrapOrThrow(res, 'Agent update'), 'Agent update'); + }); + } + + restart(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerRestart({ path: { id } }); + return this.entityOrThrow(unwrapOrThrow(res, 'Restart'), 'Restart'); + }); + } + + // Raw axios: the generated SDK hasn't been regenerated for stop/start/env/ + // metrics yet. `client.instance` carries the Bearer via the api interceptor. + stop(id: string): Promise { + return this.execute(async () => { + const res = await client.instance.post(`/agents/${id}/stop`); + return this.entityOrThrow(unwrapEnvelope(res.data), 'Stop'); + }); + } + + start(id: string): Promise { + return this.execute(async () => { + const res = await client.instance.post(`/agents/${id}/start`); + return this.entityOrThrow(unwrapEnvelope(res.data), 'Start'); + }); + } + + // Raw fetch: the OpenAPI spec doesn't expose `wipeS3` as a typed query param. + // Re-attach the Bearer from the access_token cookie ourselves (the same job + // the SDK's axios interceptor does). + remove(id: string, wipeS3: boolean): Promise { + return this.execute(async () => { + const runtime = useRuntimeConfig(); + const url = new URL(`${runtime.public.apiUrl}/agents/${id}`); + if (wipeS3) url.searchParams.set('wipeS3', 'true'); + const headers: Record = {}; + const token = readAccessToken(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch(url.toString(), { + method: 'DELETE', + credentials: 'include', + headers, + }); + if (!res.ok) { + throw new Error(`Delete failed: ${res.status} ${res.statusText}`); + } + }); + } + + promoteAdmin(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerPromoteAdmin({ path: { id } }); + return this.entityOrThrow(unwrapEnvelope(res.data), 'Promote'); + }); + } + + demoteAdmin(id: string): Promise { + return this.execute(async () => { + const res = await AgentsService.agentControllerDemoteAdmin({ path: { id } }); + return this.entityOrThrow(unwrapEnvelope(res.data), 'Demote'); + }); + } + + logs(id: string): Promise { + return this.execute(async () => { + // `tail` is a required query param (last N lines); the old store omitted + // it (a latent type error) and relied on the server default. + const res = await LogsService.logControllerGetLogs({ + path: { agentId: id }, + query: { tail: '1000' }, + }); + const payload = unwrapEnvelope<{ logs?: string }>(res.data); + return typeof payload?.logs === 'string' ? payload.logs : ''; + }); + } + + env(id: string): Promise { + return this.execute(async () => { + const res = await client.instance.get(`/agents/${id}/env`); + return this.mapper.toEnvVars(unwrapEnvelope(res.data)); + }); + } + + metrics(id: string): Promise { + return this.execute(async () => { + const res = await client.instance.get(`/agents/${id}/metrics`); + return this.mapper.toMetrics(unwrapEnvelope(res.data)); + }); + } + + private entityOrThrow(payload: unknown, action: string): IAgentData { + const entity = this.mapper.toEntity(payload); + if (!entity) throw new Error(`${action} returned no agent data`); + return entity; + } +} diff --git a/admin/slices/agent/agent/data/agent.mapper.ts b/admin/slices/agent/agent/data/agent.mapper.ts new file mode 100644 index 00000000..ac1eb275 --- /dev/null +++ b/admin/slices/agent/agent/data/agent.mapper.ts @@ -0,0 +1,129 @@ +import type { + CreateAgentDto, + UpdateAgentDto, +} from '#api/data/repositories/api/types.gen'; +import type { + AgentStatusTypes, + IAgentData, + IAgentEnvVar, + IAgentMetrics, + IAgentResources, + ICreateAgentData, + IUpdateAgentData, +} from '../domain/agent.types'; + +const KNOWN_STATUSES = new Set([ + 'pending', + 'deploying', + 'running', + 'failed', + 'stopped', +]); + +function num(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +function strList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((x): x is string => typeof x === 'string') + : []; +} + +/** + * Maps the agents API onto domain shapes. Endpoints are typed as `unknown` + * (no response DTO), so `toEntity` reads defensively and drops anything without + * a string `id`. Input payloads are structurally the wire DTOs except + * `llmCredentialId`, which the domain models as `string | null` but the DTO as + * `string | undefined` — coerce null → undefined so "no credential" round-trips. + */ +export class AgentMapper { + toEntity(raw: unknown): IAgentData | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.id !== 'string') return null; + return { + id: o.id, + name: typeof o.name === 'string' ? o.name : '', + templateId: typeof o.templateId === 'string' ? o.templateId : '', + llmCredentialId: + typeof o.llmCredentialId === 'string' ? o.llmCredentialId : null, + status: this.toStatus(o.status), + workflowId: typeof o.workflowId === 'string' ? o.workflowId : null, + config: + o.config && typeof o.config === 'object' + ? (o.config as Record) + : {}, + resources: this.toResources(o.resources), + isPublic: o.isPublic === true, + allowedOrigins: strList(o.allowedOrigins), + knowledgeIds: strList(o.knowledgeIds), + isAdmin: o.isAdmin === true, + debugEnabled: o.debugEnabled === true, + createdAt: typeof o.createdAt === 'string' ? o.createdAt : '', + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : '', + }; + } + + toList(raw: unknown): IAgentData[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => this.toEntity(item)) + .filter((a): a is IAgentData => a !== null); + } + + toMetrics(raw: unknown): IAgentMetrics | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const pod = (o.pod ?? {}) as Record; + const node = (o.node ?? {}) as Record; + if (!o.pod || !o.node) return null; + return { + pod: { + cpuMilli: num(pod.cpuMilli), + memBytes: num(pod.memBytes), + cpuLimitMilli: num(pod.cpuLimitMilli), + memLimitBytes: num(pod.memLimitBytes), + }, + node: { + name: typeof node.name === 'string' ? node.name : '', + diskAvailBytes: num(node.diskAvailBytes), + diskCapacityBytes: num(node.diskCapacityBytes), + }, + }; + } + + toEnvVars(raw: unknown): IAgentEnvVar[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((e): e is Record => !!e && typeof e === 'object') + .map((e) => ({ + name: typeof e.name === 'string' ? e.name : '', + value: typeof e.value === 'string' ? e.value : '', + })); + } + + toCreateDto(input: ICreateAgentData): CreateAgentDto { + return { ...input, llmCredentialId: input.llmCredentialId ?? undefined }; + } + + toUpdateDto(input: IUpdateAgentData): UpdateAgentDto { + return { ...input, llmCredentialId: input.llmCredentialId ?? undefined }; + } + + // ── internals ────────────────────────────────────────────────────────── + + private toStatus(raw: unknown): AgentStatusTypes { + return typeof raw === 'string' && KNOWN_STATUSES.has(raw as AgentStatusTypes) + ? (raw as AgentStatusTypes) + : 'pending'; + } + + private toResources(raw: unknown): IAgentResources { + const r = raw && typeof raw === 'object' ? (raw as Record) : {}; + return { + cpu: typeof r.cpu === 'string' ? r.cpu : '', + memory: typeof r.memory === 'string' ? r.memory : '', + }; + } +} diff --git a/admin/slices/agent/agent/data/agentStatus.gateway.ts b/admin/slices/agent/agent/data/agentStatus.gateway.ts new file mode 100644 index 00000000..8bbf518c --- /dev/null +++ b/admin/slices/agent/agent/data/agentStatus.gateway.ts @@ -0,0 +1,57 @@ +import { IAgentStatusGateway } from '../domain/agentStatus.gateway'; +import type { IAgentStatusCallbacks } from '../domain/agentStatus.types'; +import { AgentStatusMapper } from './agentStatus.mapper'; + +/** + * Owns the `/agents/status/stream` EventSource. Opens the stream on the first + * `subscribe`, forwards decoded frames + connection-state changes to the store's + * callbacks, and closes on `unsubscribe`. The store does the consumer + * ref-counting, so subscribe/unsubscribe are called once per open/close cycle. + */ +export class AgentStatusGateway implements IAgentStatusGateway { + private mapper = new AgentStatusMapper(); + private source: EventSource | null = null; + private callbacks: IAgentStatusCallbacks | null = null; + + constructor(private getBaseUrl: () => string) {} + + subscribe(callbacks: IAgentStatusCallbacks): void { + if (!import.meta.client) return; + this.callbacks = callbacks; + if (this.source) return; + + callbacks.onConnectionStateChange('connecting'); + const baseUrl = this.getBaseUrl().replace(/\/$/, ''); + this.source = new EventSource(`${baseUrl}/agents/status/stream`); + + this.source.onopen = () => { + this.callbacks?.onConnectionStateChange('connected'); + }; + + this.source.onmessage = (raw: MessageEvent) => { + let parsed: unknown; + try { + parsed = JSON.parse(raw.data); + } catch (err) { + // malformed payload — keep the connection but skip the message + console.warn('[agentStatus] failed to parse SSE payload', err); + return; + } + const message = this.mapper.toStreamMessage(parsed); + if (message) this.callbacks?.onMessage(message); + }; + + this.source.onerror = () => { + // EventSource handles reconnection on its own; reflect intermediate state + this.callbacks?.onConnectionStateChange('disconnected'); + }; + } + + unsubscribe(): void { + if (this.source) { + this.source.close(); + this.source = null; + } + this.callbacks = null; + } +} diff --git a/admin/slices/agent/agent/data/agentStatus.mapper.ts b/admin/slices/agent/agent/data/agentStatus.mapper.ts new file mode 100644 index 00000000..5469f0d4 --- /dev/null +++ b/admin/slices/agent/agent/data/agentStatus.mapper.ts @@ -0,0 +1,80 @@ +import type { + AgentStatusEventType, + AgentStatusStreamMessage, + IAgentPodStatus, + IAgentRecord, + IAgentStatus, +} from '../domain/agentStatus.types'; + +const EVENT_TYPES = new Set([ + 'added', + 'modified', + 'deleted', +]); + +/** + * Decodes a raw status-stream frame into a typed domain message, validating the + * discriminant (`type` / `eventType`) and requiring a well-formed agent record + * so the store's reducer can't crash on a malformed payload. + */ +export class AgentStatusMapper { + toStreamMessage(raw: unknown): AgentStatusStreamMessage | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + + if (o.type === 'snapshot') { + const items = Array.isArray(o.payload) ? o.payload : []; + return { + type: 'snapshot', + payload: items + .map((s) => this.toStatus(s)) + .filter((s): s is IAgentStatus => s !== null), + }; + } + + if (o.type === 'event') { + const p = + o.payload && typeof o.payload === 'object' + ? (o.payload as Record) + : null; + if (!p) return null; + const eventType = p.eventType; + if (typeof eventType !== 'string' || !EVENT_TYPES.has(eventType as AgentStatusEventType)) { + return null; + } + const status = this.toStatus(p.status); + if (!status) return null; + return { + type: 'event', + payload: { eventType: eventType as AgentStatusEventType, status }, + }; + } + + return null; + } + + private toStatus(raw: unknown): IAgentStatus | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + const agent = this.toAgent(o.agent); + if (!agent) return null; + return { agent, pod: this.toPod(o.pod) }; + } + + private toAgent(raw: unknown): IAgentRecord | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.id !== 'string') return null; + return { + id: o.id, + name: typeof o.name === 'string' ? o.name : '', + status: typeof o.status === 'string' ? o.status : '', + }; + } + + // Pod fields come from our own API; validate presence and trust the shape. + private toPod(raw: unknown): IAgentPodStatus | null { + if (!raw || typeof raw !== 'object') return null; + return raw as IAgentPodStatus; + } +} diff --git a/admin/slices/agent/agent/data/index.ts b/admin/slices/agent/agent/data/index.ts new file mode 100644 index 00000000..9e554531 --- /dev/null +++ b/admin/slices/agent/agent/data/index.ts @@ -0,0 +1,4 @@ +export * from './agent.gateway'; +export * from './agent.mapper'; +export * from './agentStatus.gateway'; +export * from './agentStatus.mapper'; diff --git a/admin/slices/agent/agent/domain/agent.gateway.ts b/admin/slices/agent/agent/domain/agent.gateway.ts new file mode 100644 index 00000000..350e6cac --- /dev/null +++ b/admin/slices/agent/agent/domain/agent.gateway.ts @@ -0,0 +1,30 @@ +import type { + IAgentData, + IAgentEnvVar, + IAgentMetrics, + ICreateAgentData, + IUpdateAgentData, +} from './agent.types'; + +/** + * Contract for the admin agents API. Implemented by `AgentGateway` in the data + * layer, which hides the mixed transport (generated SDK + raw axios for + * not-yet-typed endpoints + raw fetch for the wipeS3 delete). The service and + * store depend only on this abstraction. + */ +export abstract class IAgentGateway { + abstract findAll(): Promise; + abstract findById(id: string): Promise; + abstract findAdmin(): Promise; + abstract create(input: ICreateAgentData): Promise; + abstract update(id: string, input: IUpdateAgentData): Promise; + abstract restart(id: string): Promise; + abstract stop(id: string): Promise; + abstract start(id: string): Promise; + abstract remove(id: string, wipeS3: boolean): Promise; + abstract promoteAdmin(id: string): Promise; + abstract demoteAdmin(id: string): Promise; + abstract logs(id: string): Promise; + abstract env(id: string): Promise; + abstract metrics(id: string): Promise; +} diff --git a/admin/slices/agent/agent/domain/agent.service.ts b/admin/slices/agent/agent/domain/agent.service.ts new file mode 100644 index 00000000..034b4036 --- /dev/null +++ b/admin/slices/agent/agent/domain/agent.service.ts @@ -0,0 +1,74 @@ +import type { IAgentGateway } from './agent.gateway'; +import type { + IAgentData, + IAgentEnvVar, + IAgentMetrics, + ICreateAgentData, + IUpdateAgentData, +} from './agent.types'; + +/** + * Domain service for admin agent management. Exposes the CRUD + lifecycle + * (restart/stop/start) + introspection (logs/env/metrics) use-cases; the store + * layers the reactive agent list, optimistic status flips, and the localStorage + * restart flags on top. + */ +export class AgentService { + constructor(private gateway: IAgentGateway) {} + + findAll(): Promise { + return this.gateway.findAll(); + } + + findById(id: string): Promise { + return this.gateway.findById(id); + } + + findAdmin(): Promise { + return this.gateway.findAdmin(); + } + + create(input: ICreateAgentData): Promise { + return this.gateway.create(input); + } + + update(id: string, input: IUpdateAgentData): Promise { + return this.gateway.update(id, input); + } + + restart(id: string): Promise { + return this.gateway.restart(id); + } + + stop(id: string): Promise { + return this.gateway.stop(id); + } + + start(id: string): Promise { + return this.gateway.start(id); + } + + remove(id: string, wipeS3: boolean): Promise { + return this.gateway.remove(id, wipeS3); + } + + promoteAdmin(id: string): Promise { + return this.gateway.promoteAdmin(id); + } + + demoteAdmin(id: string): Promise { + return this.gateway.demoteAdmin(id); + } + + logs(id: string): Promise { + return this.gateway.logs(id); + } + + env(id: string): Promise { + return this.gateway.env(id); + } + + metrics(id: string): Promise { + return this.gateway.metrics(id); + } +} diff --git a/admin/slices/agent/agent/domain/agent.types.ts b/admin/slices/agent/agent/domain/agent.types.ts new file mode 100644 index 00000000..52bb9f01 --- /dev/null +++ b/admin/slices/agent/agent/domain/agent.types.ts @@ -0,0 +1,76 @@ +// Domain types for the admin agent slice. Envelope-free; the data layer maps +// the (loosely-typed `unknown`) API responses onto these and converts domain +// input payloads to the wire DTOs. + +export type AgentStatusTypes = + | 'pending' + | 'deploying' + | 'running' + | 'failed' + | 'stopped'; + +export interface IAgentResources { + cpu: string; + memory: string; +} + +export interface IAgentMetrics { + pod: { + cpuMilli: number; + memBytes: number; + cpuLimitMilli: number; + memLimitBytes: number; + }; + node: { + name: string; + diskAvailBytes: number; + diskCapacityBytes: number; + }; +} + +export interface IAgentEnvVar { + name: string; + value: string; +} + +export interface IAgentData { + id: string; + name: string; + templateId: string; + llmCredentialId: string | null; + status: AgentStatusTypes; + workflowId: string | null; + config: Record; + resources: IAgentResources; + isPublic: boolean; + allowedOrigins: string[]; + knowledgeIds: string[]; + isAdmin: boolean; + debugEnabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface ICreateAgentData { + name: string; + templateId: string; + llmCredentialId?: string | null; + config?: Record; + resources?: IAgentResources; + isPublic?: boolean; + allowedOrigins?: string[]; + knowledgeIds?: string[]; + isAdmin?: boolean; +} + +export interface IUpdateAgentData { + name?: string; + templateId?: string; + llmCredentialId?: string | null; + config?: Record; + resources?: IAgentResources; + isPublic?: boolean; + allowedOrigins?: string[]; + knowledgeIds?: string[]; + debugEnabled?: boolean; +} diff --git a/admin/slices/agent/agent/domain/agentStatus.gateway.ts b/admin/slices/agent/agent/domain/agentStatus.gateway.ts new file mode 100644 index 00000000..1983ed8d --- /dev/null +++ b/admin/slices/agent/agent/domain/agentStatus.gateway.ts @@ -0,0 +1,11 @@ +import type { IAgentStatusCallbacks } from './agentStatus.types'; + +/** + * Contract for the live agent-status stream. Implemented by `AgentStatusGateway` + * in the data layer (which owns the EventSource). `subscribe` opens the stream + * and drives the callbacks; `unsubscribe` tears it down. + */ +export abstract class IAgentStatusGateway { + abstract subscribe(callbacks: IAgentStatusCallbacks): void; + abstract unsubscribe(): void; +} diff --git a/admin/slices/agent/agent/domain/agentStatus.service.ts b/admin/slices/agent/agent/domain/agentStatus.service.ts new file mode 100644 index 00000000..79f8e4a5 --- /dev/null +++ b/admin/slices/agent/agent/domain/agentStatus.service.ts @@ -0,0 +1,19 @@ +import type { IAgentStatusGateway } from './agentStatus.gateway'; +import type { IAgentStatusCallbacks } from './agentStatus.types'; + +/** + * Domain service for the live agent-status stream. Thin today (forwards to the + * gateway); cross-cutting concerns (reconnect policy, multiplexing) would land + * here without touching the store. + */ +export class AgentStatusService { + constructor(private gateway: IAgentStatusGateway) {} + + subscribe(callbacks: IAgentStatusCallbacks): void { + this.gateway.subscribe(callbacks); + } + + unsubscribe(): void { + this.gateway.unsubscribe(); + } +} diff --git a/admin/slices/agent/agent/domain/agentStatus.types.ts b/admin/slices/agent/agent/domain/agentStatus.types.ts new file mode 100644 index 00000000..8be3b0a8 --- /dev/null +++ b/admin/slices/agent/agent/domain/agentStatus.types.ts @@ -0,0 +1,49 @@ +// Domain types for the live agent-status stream (SSE). The data layer manages +// the EventSource transport and parses raw frames into these; the store applies +// them to reactive state. + +export type ConnectionStateTypes = + | 'idle' + | 'connecting' + | 'connected' + | 'disconnected'; + +export interface IAgentPodStatus { + agentId: string; + podName: string; + phase: 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'; + ready: boolean; + restartCount: number; + startedAt: string | null; + lastTerminationReason: string | null; + containerWaitingReason: string | null; + message: string | null; + observedAt: string; +} + +export interface IAgentRecord { + id: string; + name: string; + status: string; +} + +export interface IAgentStatus { + agent: IAgentRecord; + pod: IAgentPodStatus | null; +} + +export type AgentStatusEventType = 'added' | 'modified' | 'deleted'; + +/** One decoded frame off the status stream: a full snapshot or a single delta. */ +export type AgentStatusStreamMessage = + | { type: 'snapshot'; payload: IAgentStatus[] } + | { + type: 'event'; + payload: { eventType: AgentStatusEventType; status: IAgentStatus }; + }; + +/** Callbacks the store hands to the subscription service. */ +export interface IAgentStatusCallbacks { + onMessage: (message: AgentStatusStreamMessage) => void; + onConnectionStateChange: (state: ConnectionStateTypes) => void; +} diff --git a/admin/slices/agent/agent/domain/index.ts b/admin/slices/agent/agent/domain/index.ts new file mode 100644 index 00000000..bb345982 --- /dev/null +++ b/admin/slices/agent/agent/domain/index.ts @@ -0,0 +1,6 @@ +export * from './agent.gateway'; +export * from './agent.service'; +export * from './agent.types'; +export * from './agentStatus.gateway'; +export * from './agentStatus.service'; +export * from './agentStatus.types'; diff --git a/admin/slices/agent/agent/index.d.ts b/admin/slices/agent/agent/index.d.ts new file mode 100644 index 00000000..3f30c930 --- /dev/null +++ b/admin/slices/agent/agent/index.d.ts @@ -0,0 +1,11 @@ +import type { AgentService } from './domain/agent.service'; +import type { AgentStatusService } from './domain/agentStatus.service'; + +declare module '#app' { + interface NuxtApp { + $agentService: AgentService; + $agentStatusService: AgentStatusService; + } +} + +export {}; diff --git a/admin/slices/agent/agent/plugins/di.ts b/admin/slices/agent/agent/plugins/di.ts new file mode 100644 index 00000000..74474b08 --- /dev/null +++ b/admin/slices/agent/agent/plugins/di.ts @@ -0,0 +1,28 @@ +import { AgentGateway } from '../data/agent.gateway'; +import { AgentStatusGateway } from '../data/agentStatus.gateway'; +import { AgentService } from '../domain/agent.service'; +import { AgentStatusService } from '../domain/agentStatus.service'; + +/** + * Composition root for the agent slice. Builds the service graphs once and + * provides them: `$agentService` (CRUD + lifecycle) and `$agentStatusService` + * (the live SSE status stream). + */ +export default defineNuxtPlugin({ + name: 'agent-di', + setup() { + const runtime = useRuntimeConfig(); + + const agentService = new AgentService(new AgentGateway()); + const agentStatusService = new AgentStatusService( + new AgentStatusGateway(() => runtime.public.apiUrl as string), + ); + + return { + provide: { + agentService, + agentStatusService, + }, + }; + }, +}); diff --git a/admin/slices/agent/agent/stores/agent.ts b/admin/slices/agent/agent/stores/agent.ts index bcf3d0f4..053927e8 100644 --- a/admin/slices/agent/agent/stores/agent.ts +++ b/admin/slices/agent/agent/stores/agent.ts @@ -1,113 +1,25 @@ -import { AgentsService, LogsService } from '#api/data'; -import { client } from '#api/data/repositories/api/client.gen'; - -type ApiEnvelope = { success: boolean; data: T }; - -/** - * Hey API's axios client never throws by default — it returns - * `{ data, error, response }`. Crucially, on a *network-level* failure (the - * request never gets an HTTP response — CORS rejection, API down, blocked by - * an extension) it sets `error` to an empty `{}`. That object is truthy but - * has no `.message`, so a naive `error.message ?? 'fallback'` surfaces a - * meaningless fallback and hides the real cause. - * - * This helper turns every failure mode into an Error that says what actually - * happened, and returns the unwrapped `data` payload on success. - */ -function unwrapResponse(res: unknown, action: string): T { - const r = res as { - data?: ApiEnvelope; - error?: unknown; - response?: { status?: number }; - }; - const status = r.response?.status; - const err = r.error; - if (err !== undefined && err !== null) { - const message = (err as { message?: string }).message; - if (message) throw new Error(`${action} failed: ${message}`); - if (status && status >= 400) { - throw new Error(`${action} failed: HTTP ${status}`); - } - // Empty error + no status → the request never reached the API. - throw new Error( - `${action} failed: could not reach the API. Check that the API is ` + - 'running and that this app\'s origin is listed in CORS_ORIGIN.', - ); - } - const env = r.data; - if (!env || env.success === false || env.data === undefined) { - throw new Error(`${action} failed: the API returned no data.`); - } - return env.data; -} - -export type AgentStatusTypes = - | 'pending' - | 'deploying' - | 'running' - | 'failed' - | 'stopped'; - -export interface IAgentResources { - cpu: string; - memory: string; -} - -export interface IAgentMetrics { - pod: { - cpuMilli: number; - memBytes: number; - cpuLimitMilli: number; - memLimitBytes: number; - }; - node: { - name: string; - diskAvailBytes: number; - diskCapacityBytes: number; - }; -} - -export interface IAgentData { - id: string; - name: string; - templateId: string; - llmCredentialId: string | null; - status: AgentStatusTypes; - workflowId: string | null; - config: Record; - resources: IAgentResources; - isPublic: boolean; - allowedOrigins: string[]; - knowledgeIds: string[]; - isAdmin: boolean; - debugEnabled: boolean; - createdAt: string; - updatedAt: string; -} - -export interface ICreateAgentData { - name: string; - templateId: string; - llmCredentialId?: string | null; - config?: Record; - resources?: IAgentResources; - isPublic?: boolean; - allowedOrigins?: string[]; - knowledgeIds?: string[]; - isAdmin?: boolean; -} - -export interface IUpdateAgentData { - name?: string; - templateId?: string; - llmCredentialId?: string | null; - config?: Record; - resources?: IAgentResources; - isPublic?: boolean; - allowedOrigins?: string[]; - knowledgeIds?: string[]; - debugEnabled?: boolean; -} +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { AgentService } from '#agent/domain'; +import type { + IAgentData, + ICreateAgentData, + IUpdateAgentData, +} from '#agent/domain'; + +// Re-export the domain types so consumers that import them from +// `#agent/stores/agent` (Form, agentList/agentCreate/agentEdit/agentVisibility +// Providers, rancher store, …) keep working. +export type { + AgentStatusTypes, + IAgentData, + IAgentEnvVar, + IAgentMetrics, + IAgentResources, + ICreateAgentData, + IUpdateAgentData, +} from '#agent/domain'; + +const getService = createServiceGetter('$agentService'); const PENDING_RESTART_KEY = 'agent:pendingRestart'; @@ -224,65 +136,45 @@ export const useAgentStore = defineStore('agent', () => { } async function fetchAll() { - const res = await AgentsService.agentControllerFindAll(); - const env = res.data as ApiEnvelope | undefined; - agents.value = env?.data ?? []; + agents.value = await getService().findAll(); return agents.value; } - async function fetchById(id: string) { - const res = await AgentsService.agentControllerFindById({ path: { id } }); - const env = res.data as ApiEnvelope | undefined; - return env?.data ?? null; + function fetchById(id: string) { + return getService().findById(id); } - async function fetchAdmin() { - const res = await AgentsService.agentControllerFindAdmin(); - const env = res.data as ApiEnvelope | undefined; - return env?.data ?? null; + function fetchAdmin() { + return getService().findAdmin(); } async function create(data: ICreateAgentData) { - const res = await AgentsService.agentControllerCreate({ body: data }); - const env = res.data as ApiEnvelope; - agents.value = [env.data, ...agents.value]; - return env.data; + const created = await getService().create(data); + agents.value = [created, ...agents.value]; + return created; } async function update(id: string, data: IUpdateAgentData) { - const res = await AgentsService.agentControllerUpdate({ - path: { id }, - body: data, - }); - const updated = unwrapResponse(res, 'Agent update'); + const updated = await getService().update(id, data); agents.value = agents.value.map((a) => (a.id === id ? updated : a)); return updated; } - async function restart(id: string) { - // Optimistic: flip the status to "deploying" so the UI reacts immediately - // (the restart endpoint cancels the old workflow and submits a new one, - // which can take several seconds). Revert on error. + // Optimistic: flip the status so the UI reacts immediately (the lifecycle + // endpoints take several seconds). Revert on error. + async function withOptimisticStatus( + id: string, + optimistic: IAgentData['status'], + run: () => Promise, + ) { const previous = agents.value.find((a) => a.id === id); - if (previous && previous.status !== 'deploying') { + if (previous && previous.status !== optimistic) { agents.value = agents.value.map((a) => - a.id === id ? { ...a, status: 'deploying' } : a, + a.id === id ? { ...a, status: optimistic } : a, ); } try { - const res = await AgentsService.agentControllerRestart({ path: { id } }); - // Hey API axios returns `{ data, error, response }`. On non-2xx it leaves - // `data` undefined and surfaces the body in `error`. Surface a meaningful - // message instead of crashing on `env.data` access. - const errBody = (res as { error?: { message?: string } }).error; - if (errBody) { - throw new Error(errBody.message ?? 'Restart failed'); - } - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data; - if (!updated) { - throw new Error('Restart returned no agent data'); - } + const updated = await run(); agents.value = agents.value.map((a) => (a.id === id ? updated : a)); return updated; } catch (err) { @@ -293,89 +185,25 @@ export const useAgentStore = defineStore('agent', () => { } } - // Stop an agent — cancels its workflow and deletes its pod to free cluster - // resources, keeping the row so it can be started again. Raw axios call: - // the generated SDK hasn't been regenerated for this endpoint yet (same - // approach as fetchEnv/fetchMetrics). Optimistic flip to 'stopped'. - async function stop(id: string) { - const previous = agents.value.find((a) => a.id === id); - if (previous && previous.status !== 'stopped') { - agents.value = agents.value.map((a) => - a.id === id ? { ...a, status: 'stopped' } : a, - ); - } - try { - const res = await client.instance.post(`/agents/${id}/stop`); - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data; - if (!updated) throw new Error('Stop returned no agent data'); - agents.value = agents.value.map((a) => (a.id === id ? updated : a)); - return updated; - } catch (err) { - if (previous) { - agents.value = agents.value.map((a) => (a.id === id ? previous : a)); - } - throw err; - } + function restart(id: string) { + return withOptimisticStatus(id, 'deploying', () => getService().restart(id)); } - // Start a stopped agent — deploys a fresh pod. Optimistic flip to - // 'deploying' so the badge reacts before the API resolves. - async function start(id: string) { - const previous = agents.value.find((a) => a.id === id); - if (previous && previous.status !== 'deploying') { - agents.value = agents.value.map((a) => - a.id === id ? { ...a, status: 'deploying' } : a, - ); - } - try { - const res = await client.instance.post(`/agents/${id}/start`); - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data; - if (!updated) throw new Error('Start returned no agent data'); - agents.value = agents.value.map((a) => (a.id === id ? updated : a)); - return updated; - } catch (err) { - if (previous) { - agents.value = agents.value.map((a) => (a.id === id ? previous : a)); - } - throw err; - } + function stop(id: string) { + return withOptimisticStatus(id, 'stopped', () => getService().stop(id)); } - async function remove(id: string, options: { wipeS3?: boolean } = {}) { - // Use raw fetch because the OpenAPI spec doesn't yet expose `wipeS3` - // as a typed query param. We re-attach the Bearer token from the - // access_token cookie ourselves — same job the SDK's axios - // interceptor does in api/plugins/apiBaseUrl.ts. - const runtime = useRuntimeConfig(); - const url = new URL(`${runtime.public.apiUrl}/agents/${id}`); - if (options.wipeS3) url.searchParams.set('wipeS3', 'true'); - const headers: Record = {}; - const token = readAccessToken(); - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch(url.toString(), { - method: 'DELETE', - credentials: 'include', - headers, - }); - if (!res.ok) { - throw new Error(`Delete failed: ${res.status} ${res.statusText}`); - } - agents.value = agents.value.filter((a) => a.id !== id); + function start(id: string) { + return withOptimisticStatus(id, 'deploying', () => getService().start(id)); } - function readAccessToken(): string | null { - if (typeof document === 'undefined') return null; - const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/); - return m ? decodeURIComponent(m[1]) : null; + async function remove(id: string, options: { wipeS3?: boolean } = {}) { + await getService().remove(id, options.wipeS3 ?? false); + agents.value = agents.value.filter((a) => a.id !== id); } async function promoteAdmin(id: string) { - const res = await AgentsService.agentControllerPromoteAdmin({ path: { id } }); - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data; - if (!updated) throw new Error('Promote returned no agent data'); + const updated = await getService().promoteAdmin(id); // Single-admin invariant — local cache must mirror the server. Drop the // flag from any other agent so two badges never appear at once. agents.value = agents.value.map((a) => @@ -385,37 +213,21 @@ export const useAgentStore = defineStore('agent', () => { } async function demoteAdmin(id: string) { - const res = await AgentsService.agentControllerDemoteAdmin({ path: { id } }); - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data; - if (!updated) throw new Error('Demote returned no agent data'); + const updated = await getService().demoteAdmin(id); agents.value = agents.value.map((a) => (a.id === id ? updated : a)); return updated; } - async function fetchLogs(id: string): Promise { - const res = await LogsService.logControllerGetLogs({ path: { agentId: id } }); - const env = res.data as ApiEnvelope<{ logs: string }> | undefined; - return env?.data?.logs ?? ''; + function fetchLogs(id: string): Promise { + return getService().logs(id); } - // Env preview — the API builds it with the same code as the real pod - // manifest, so the admin panel can't drift. Raw axios call: the generated - // SDK hasn't been regenerated for this endpoint yet. - async function fetchEnv( - id: string, - ): Promise<{ name: string; value: string }[]> { - const res = await client.instance.get(`/agents/${id}/env`); - const env = res.data as - | ApiEnvelope<{ name: string; value: string }[]> - | undefined; - return env?.data ?? []; + function fetchEnv(id: string) { + return getService().env(id); } - async function fetchMetrics(id: string): Promise { - const res = await client.instance.get(`/agents/${id}/metrics`); - const env = res.data as ApiEnvelope | undefined; - return env?.data ?? null; + function fetchMetrics(id: string) { + return getService().metrics(id); } return { diff --git a/admin/slices/agent/agent/stores/agentStatus.ts b/admin/slices/agent/agent/stores/agentStatus.ts index 2693ed1b..7cda43a3 100644 --- a/admin/slices/agent/agent/stores/agentStatus.ts +++ b/admin/slices/agent/agent/stores/agentStatus.ts @@ -1,46 +1,33 @@ -export type ConnectionStateTypes = 'idle' | 'connecting' | 'connected' | 'disconnected'; - -export interface IAgentPodStatus { - agentId: string; - podName: string; - phase: 'Pending' | 'Running' | 'Succeeded' | 'Failed' | 'Unknown'; - ready: boolean; - restartCount: number; - startedAt: string | null; - lastTerminationReason: string | null; - containerWaitingReason: string | null; - message: string | null; - observedAt: string; -} - -interface IAgentRecord { - id: string; - name: string; - status: string; -} - -interface IAgentStatus { - agent: IAgentRecord; - pod: IAgentPodStatus | null; -} - -type StreamMessage = - | { type: 'snapshot'; payload: IAgentStatus[] } - | { - type: 'event'; - payload: { eventType: 'added' | 'modified' | 'deleted'; status: IAgentStatus }; - }; +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { AgentStatusService } from '#agent/domain'; +import type { + AgentStatusStreamMessage, + ConnectionStateTypes, + IAgentPodStatus, + IAgentRecord, +} from '#agent/domain'; + +// Re-export the domain types so any consumer importing them from +// `#agent/stores/agentStatus` keeps working. +export type { + ConnectionStateTypes, + IAgentPodStatus, + IAgentRecord, + IAgentStatus, +} from '#agent/domain'; + +const getService = createServiceGetter('$agentStatusService'); export const useAgentStatusStore = defineStore('agentStatus', () => { - const config = useRuntimeConfig(); - const connectionState = ref('idle'); const lastEventAt = ref(null); const statuses = ref>({}); const agents = ref>({}); - let source: EventSource | null = null; + // Consumer ref-count: multiple components mount/unmount the stream, but only + // one EventSource should be open. `active` guards the single subscribe. let subscribers = 0; + let active = false; const connected = computed(() => connectionState.value === 'connected'); @@ -55,7 +42,7 @@ export const useAgentStatusStore = defineStore('agentStatus', () => { ).length, ); - function applyMessage(msg: StreamMessage) { + function applyMessage(msg: AgentStatusStreamMessage) { lastEventAt.value = Date.now(); if (msg.type === 'snapshot') { const nextAgents: Record = {}; @@ -83,36 +70,21 @@ export const useAgentStatusStore = defineStore('agentStatus', () => { function connect() { subscribers += 1; - if (source || !import.meta.client) return; - - connectionState.value = 'connecting'; - const baseUrl = (config.public.apiUrl as string).replace(/\/$/, ''); - source = new EventSource(`${baseUrl}/agents/status/stream`); - - source.onopen = () => { - connectionState.value = 'connected'; - }; - - source.onmessage = (raw: MessageEvent) => { - try { - applyMessage(JSON.parse(raw.data) as StreamMessage); - } catch (err) { - // malformed payload — keep the connection but skip the message - console.warn('[agentStatus] failed to parse SSE payload', err); - } - }; - - source.onerror = () => { - // EventSource handles reconnection on its own; reflect intermediate state - connectionState.value = 'disconnected'; - }; + if (active || !import.meta.client) return; + active = true; + getService().subscribe({ + onMessage: applyMessage, + onConnectionStateChange: (state) => { + connectionState.value = state; + }, + }); } function disconnect() { subscribers = Math.max(0, subscribers - 1); - if (subscribers > 0 || !source) return; - source.close(); - source = null; + if (subscribers > 0 || !active) return; + active = false; + getService().unsubscribe(); connectionState.value = 'idle'; } diff --git a/admin/slices/agent/agentChannel/data/agentChannel.gateway.ts b/admin/slices/agent/agentChannel/data/agentChannel.gateway.ts new file mode 100644 index 00000000..dc01221c --- /dev/null +++ b/admin/slices/agent/agentChannel/data/agentChannel.gateway.ts @@ -0,0 +1,38 @@ +import { AgentsService } from '#api/data'; +import type { AgentChannelDto } from '#api/data/repositories/api/types.gen'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IAgentChannelGateway } from '../domain/agentChannel.gateway'; +import type { IAgentChannel } from '../domain/agentChannel.types'; +import { AgentChannelMapper } from './agentChannel.mapper'; + +export class AgentChannelGateway + extends BaseGateway + implements IAgentChannelGateway +{ + private mapper = new AgentChannelMapper(); + + fetchForAgent(agentId: string): Promise { + return this.execute(async () => { + const res = await AgentsService.getAgentChannels({ path: { id: agentId } }); + const dto = unwrapEnvelope(res.data); + return dto ? this.mapper.toList(dto) : []; + }); + } + + setForAgent( + agentId: string, + channels: IAgentChannel[], + ): Promise { + return this.execute(async () => { + const res = await AgentsService.setAgentChannels({ + path: { id: agentId }, + body: { channels: this.mapper.toDtoList(channels) }, + }); + const dto = unwrapEnvelope(res.data); + // On an empty envelope, fall back to the just-submitted channels so the + // caller's optimistic view stays correct. + return dto ? this.mapper.toList(dto) : channels; + }); + } +} diff --git a/admin/slices/agent/agentChannel/data/agentChannel.mapper.ts b/admin/slices/agent/agentChannel/data/agentChannel.mapper.ts new file mode 100644 index 00000000..71fd3073 --- /dev/null +++ b/admin/slices/agent/agentChannel/data/agentChannel.mapper.ts @@ -0,0 +1,46 @@ +import type { AgentChannelDto } from '#api/data/repositories/api/types.gen'; +import type { IAgentChannel } from '../domain/agentChannel.types'; + +/** + * Maps the agent-channels API onto the domain shape. `IAgentChannel` is + * structurally the wire `AgentChannelDto`; the mapper still exists to keep + * `#api` types out of the domain and to drop malformed channels defensively. + */ +export class AgentChannelMapper { + toList(raw: unknown): IAgentChannel[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => this.toChannel(item)) + .filter((c): c is IAgentChannel => c !== null); + } + + toDtoList(channels: IAgentChannel[]): AgentChannelDto[] { + return channels.map((c) => ({ + type: c.type, + config: { + botToken: c.config.botToken, + botName: c.config.botName, + adminIds: c.config.adminIds, + }, + })); + } + + private toChannel(raw: unknown): IAgentChannel | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (o.type !== 'telegram') return null; + const config = + o.config && typeof o.config === 'object' + ? (o.config as Record) + : {}; + if (typeof config.botToken !== 'string') return null; + return { + type: 'telegram', + config: { + botToken: config.botToken, + botName: typeof config.botName === 'string' ? config.botName : undefined, + adminIds: typeof config.adminIds === 'string' ? config.adminIds : undefined, + }, + }; + } +} diff --git a/admin/slices/agent/agentChannel/data/index.ts b/admin/slices/agent/agentChannel/data/index.ts new file mode 100644 index 00000000..9f1a2ea4 --- /dev/null +++ b/admin/slices/agent/agentChannel/data/index.ts @@ -0,0 +1,2 @@ +export * from './agentChannel.gateway'; +export * from './agentChannel.mapper'; diff --git a/admin/slices/agent/agentChannel/domain/agentChannel.gateway.ts b/admin/slices/agent/agentChannel/domain/agentChannel.gateway.ts new file mode 100644 index 00000000..017d09df --- /dev/null +++ b/admin/slices/agent/agentChannel/domain/agentChannel.gateway.ts @@ -0,0 +1,13 @@ +import type { IAgentChannel } from './agentChannel.types'; + +/** + * Contract for an agent's channel configuration. Implemented by + * `AgentChannelGateway` in the data layer. + */ +export abstract class IAgentChannelGateway { + abstract fetchForAgent(agentId: string): Promise; + abstract setForAgent( + agentId: string, + channels: IAgentChannel[], + ): Promise; +} diff --git a/admin/slices/agent/agentChannel/domain/agentChannel.service.ts b/admin/slices/agent/agentChannel/domain/agentChannel.service.ts new file mode 100644 index 00000000..61f6861d --- /dev/null +++ b/admin/slices/agent/agentChannel/domain/agentChannel.service.ts @@ -0,0 +1,21 @@ +import type { IAgentChannelGateway } from './agentChannel.gateway'; +import type { IAgentChannel } from './agentChannel.types'; + +/** + * Domain service for an agent's channels. The store layers a per-agent cache + * (so synchronous consumers can read the last-fetched value) on top. + */ +export class AgentChannelService { + constructor(private gateway: IAgentChannelGateway) {} + + fetchForAgent(agentId: string): Promise { + return this.gateway.fetchForAgent(agentId); + } + + setForAgent( + agentId: string, + channels: IAgentChannel[], + ): Promise { + return this.gateway.setForAgent(agentId, channels); + } +} diff --git a/admin/slices/agent/agentChannel/domain/agentChannel.types.ts b/admin/slices/agent/agentChannel/domain/agentChannel.types.ts new file mode 100644 index 00000000..0bd196fa --- /dev/null +++ b/admin/slices/agent/agentChannel/domain/agentChannel.types.ts @@ -0,0 +1,11 @@ +// Domain types for an agent's messaging channels. Discriminated union — v1 is +// telegram-only; add variants here as the backend gains support. + +export type IAgentChannel = { + type: 'telegram'; + config: { + botToken: string; + botName?: string; + adminIds?: string; + }; +}; diff --git a/admin/slices/agent/agentChannel/domain/index.ts b/admin/slices/agent/agentChannel/domain/index.ts new file mode 100644 index 00000000..0ce09daa --- /dev/null +++ b/admin/slices/agent/agentChannel/domain/index.ts @@ -0,0 +1,3 @@ +export * from './agentChannel.gateway'; +export * from './agentChannel.service'; +export * from './agentChannel.types'; diff --git a/admin/slices/agent/agentChannel/index.d.ts b/admin/slices/agent/agentChannel/index.d.ts new file mode 100644 index 00000000..489e75a1 --- /dev/null +++ b/admin/slices/agent/agentChannel/index.d.ts @@ -0,0 +1,9 @@ +import type { AgentChannelService } from './domain/agentChannel.service'; + +declare module '#app' { + interface NuxtApp { + $agentChannelService: AgentChannelService; + } +} + +export {}; diff --git a/admin/slices/agent/agentChannel/plugins/di.ts b/admin/slices/agent/agentChannel/plugins/di.ts new file mode 100644 index 00000000..8a29a1c8 --- /dev/null +++ b/admin/slices/agent/agentChannel/plugins/di.ts @@ -0,0 +1,17 @@ +import { AgentChannelGateway } from '../data/agentChannel.gateway'; +import { AgentChannelService } from '../domain/agentChannel.service'; + +/** + * Composition root for the agentChannel slice. Provides `$agentChannelService`. + */ +export default defineNuxtPlugin({ + name: 'agent-channel-di', + setup() { + const service = new AgentChannelService(new AgentChannelGateway()); + return { + provide: { + agentChannelService: service, + }, + }; + }, +}); diff --git a/admin/slices/agent/agentChannel/stores/agentChannel.ts b/admin/slices/agent/agentChannel/stores/agentChannel.ts index 1a518ef5..e6cb794c 100644 --- a/admin/slices/agent/agentChannel/stores/agentChannel.ts +++ b/admin/slices/agent/agentChannel/stores/agentChannel.ts @@ -1,17 +1,13 @@ -import { AgentsService } from '#api/data'; +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { AgentChannelService, IAgentChannel } from '#agentChannel/domain'; -type ApiEnvelope = { success: boolean; data: T }; +// Re-export the domain type so `agentChannel/Provider.vue` (and any future +// consumer importing from `#agentChannel/stores/agentChannel`) keeps working. +export type { IAgentChannel } from '#agentChannel/domain'; -// Discriminated union — mirrors the API shape. v1 is telegram-only; add new -// variants here as the backend gains support. -export type IAgentChannel = { - type: 'telegram'; - config: { - botToken: string; - botName?: string; - adminIds?: string; - }; -}; +const getService = createServiceGetter( + '$agentChannelService', +); export const useAgentChannelStore = defineStore('agentChannel', () => { // Per-agent cache. Reads always go to S3 (no client-side TTL — the tab @@ -25,9 +21,7 @@ export const useAgentChannelStore = defineStore('agentChannel', () => { } async function fetchForAgent(agentId: string): Promise { - const res = await AgentsService.getAgentChannels({ path: { id: agentId } }); - const env = res.data as ApiEnvelope | undefined; - const list = env?.data ?? []; + const list = await getService().fetchForAgent(agentId); channelsByAgent.value = { ...channelsByAgent.value, [agentId]: list }; return list; } @@ -36,12 +30,7 @@ export const useAgentChannelStore = defineStore('agentChannel', () => { agentId: string, channels: IAgentChannel[], ): Promise { - const res = await AgentsService.setAgentChannels({ - path: { id: agentId }, - body: { channels }, - }); - const env = res.data as ApiEnvelope | undefined; - const updated = env?.data ?? channels; + const updated = await getService().setForAgent(agentId, channels); channelsByAgent.value = { ...channelsByAgent.value, [agentId]: updated }; return updated; } diff --git a/admin/slices/agent/file/data/agentFile.gateway.ts b/admin/slices/agent/file/data/agentFile.gateway.ts new file mode 100644 index 00000000..5b1df3a4 --- /dev/null +++ b/admin/slices/agent/file/data/agentFile.gateway.ts @@ -0,0 +1,101 @@ +import { FilesService } from '#api/data'; +import { BaseGateway } from '#common/data/BaseGateway'; +import { unwrapEnvelope } from '#common/data/unwrapEnvelope'; +import { IAgentFileGateway } from '../domain/agentFile.gateway'; +import type { + IFileChunk, + IFileContent, + IFileNode, + ISyncResult, +} from '../domain/agentFile.types'; +import { AgentFileMapper } from './agentFile.mapper'; + +function readAccessToken(): string | null { + if (typeof document === 'undefined') return null; + const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/); + return m ? decodeURIComponent(m[1]) : null; +} + +export class AgentFileGateway extends BaseGateway implements IAgentFileGateway { + private mapper = new AgentFileMapper(); + + list(agentId: string): Promise { + return this.execute(async () => { + const res = await FilesService.fileControllerList({ path: { agentId } }); + return this.mapper.toNodeList(unwrapEnvelope(res.data)); + }); + } + + read( + agentId: string, + path: string, + offset: number, + limit: number, + ): Promise { + return this.execute(async () => { + const res = await FilesService.fileControllerRead({ + path: { agentId }, + query: { path, offset, limit }, + }); + const chunk = this.mapper.toChunk(unwrapEnvelope(res.data)); + if (!chunk) { + const err = (res as { error?: { message?: string } }).error; + throw new Error(err?.message ?? 'Failed to load file'); + } + return chunk; + }); + } + + save(agentId: string, path: string, content: string): Promise { + return this.execute(async () => { + const res = await FilesService.fileControllerSave({ + path: { agentId }, + query: { path }, + body: { content }, + }); + const updated = this.mapper.toContent(unwrapEnvelope(res.data)); + if (!updated) { + const err = (res as { error?: { message?: string } }).error; + throw new Error(err?.message ?? 'Failed to save file'); + } + return updated; + }); + } + + remove(agentId: string, path: string, recursive: boolean): Promise { + return this.execute(async () => { + const res = await FilesService.fileControllerDelete({ + path: { agentId }, + query: { path, recursive }, + }); + const payload = unwrapEnvelope<{ deleted?: number }>(res.data); + return typeof payload?.deleted === 'number' ? payload.deleted : 0; + }); + } + + sync(agentId: string): Promise { + return this.execute(async () => { + const res = await FilesService.fileControllerSync({ path: { agentId } }); + return this.mapper.toSyncResult(unwrapEnvelope(res.data)); + }); + } + + // Raw fetch: the endpoint returns raw ZIP bytes (not the JSON envelope). + // Replicate the Bearer-token attachment the SDK's axios interceptor does. + exportZip(agentId: string): Promise { + return this.execute(async () => { + const runtime = useRuntimeConfig(); + const headers: Record = {}; + const token = readAccessToken(); + if (token) headers.Authorization = `Bearer ${token}`; + const res = await fetch( + `${runtime.public.apiUrl}/agents/${agentId}/files/export`, + { credentials: 'include', headers }, + ); + if (!res.ok) { + throw new Error(`Download failed: ${res.status} ${res.statusText}`); + } + return res.blob(); + }); + } +} diff --git a/admin/slices/agent/file/data/agentFile.mapper.ts b/admin/slices/agent/file/data/agentFile.mapper.ts new file mode 100644 index 00000000..5fc6478e --- /dev/null +++ b/admin/slices/agent/file/data/agentFile.mapper.ts @@ -0,0 +1,65 @@ +import type { + IFileChunk, + IFileContent, + IFileNode, + ISyncResult, +} from '../domain/agentFile.types'; + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function num(value: unknown): number { + return typeof value === 'number' ? value : 0; +} + +/** Maps the files API onto domain shapes; reads defensively. */ +export class AgentFileMapper { + toNodeList(raw: unknown): IFileNode[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => this.toNode(item)) + .filter((n): n is IFileNode => n !== null); + } + + toContent(raw: unknown): IFileContent | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.path !== 'string') return null; + return { + path: o.path, + content: str(o.content), + size: num(o.size), + updatedAt: str(o.updatedAt), + }; + } + + toChunk(raw: unknown): IFileChunk | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.path !== 'string') return null; + return { + path: o.path, + content: str(o.content), + size: num(o.size), + totalSize: num(o.totalSize), + offset: num(o.offset), + nextOffset: typeof o.nextOffset === 'number' ? o.nextOffset : null, + hasMore: o.hasMore === true, + updatedAt: str(o.updatedAt), + }; + } + + toSyncResult(raw: unknown): ISyncResult { + if (!raw || typeof raw !== 'object') return { agentOnline: false, pushed: 0 }; + const o = raw as Record; + return { agentOnline: o.agentOnline === true, pushed: num(o.pushed) }; + } + + private toNode(raw: unknown): IFileNode | null { + if (!raw || typeof raw !== 'object') return null; + const o = raw as Record; + if (typeof o.path !== 'string') return null; + return { path: o.path, size: num(o.size), updatedAt: str(o.updatedAt) }; + } +} diff --git a/admin/slices/agent/file/data/index.ts b/admin/slices/agent/file/data/index.ts new file mode 100644 index 00000000..394f42b4 --- /dev/null +++ b/admin/slices/agent/file/data/index.ts @@ -0,0 +1,2 @@ +export * from './agentFile.gateway'; +export * from './agentFile.mapper'; diff --git a/admin/slices/agent/file/domain/agentFile.gateway.ts b/admin/slices/agent/file/domain/agentFile.gateway.ts new file mode 100644 index 00000000..8fd5910a --- /dev/null +++ b/admin/slices/agent/file/domain/agentFile.gateway.ts @@ -0,0 +1,33 @@ +import type { + IFileChunk, + IFileContent, + IFileNode, + ISyncResult, +} from './agentFile.types'; + +/** + * Contract for an agent's file workspace. Implemented by `AgentFileGateway`, + * which hides the Files SDK and the raw-fetch ZIP export. + */ +export abstract class IAgentFileGateway { + abstract list(agentId: string): Promise; + abstract read( + agentId: string, + path: string, + offset: number, + limit: number, + ): Promise; + abstract save( + agentId: string, + path: string, + content: string, + ): Promise; + abstract remove( + agentId: string, + path: string, + recursive: boolean, + ): Promise; + abstract sync(agentId: string): Promise; + /** Streams the agent's S3 prefix as a ZIP; the store triggers the download. */ + abstract exportZip(agentId: string): Promise; +} diff --git a/admin/slices/agent/file/domain/agentFile.service.ts b/admin/slices/agent/file/domain/agentFile.service.ts new file mode 100644 index 00000000..8c2267dc --- /dev/null +++ b/admin/slices/agent/file/domain/agentFile.service.ts @@ -0,0 +1,44 @@ +import type { IAgentFileGateway } from './agentFile.gateway'; +import type { + IFileChunk, + IFileContent, + IFileNode, + ISyncResult, +} from './agentFile.types'; + +/** + * Domain service for an agent's file workspace. The store layers the reactive + * tree + the localStorage restart flags on top. + */ +export class AgentFileService { + constructor(private gateway: IAgentFileGateway) {} + + list(agentId: string): Promise { + return this.gateway.list(agentId); + } + + read( + agentId: string, + path: string, + offset: number, + limit: number, + ): Promise { + return this.gateway.read(agentId, path, offset, limit); + } + + save(agentId: string, path: string, content: string): Promise { + return this.gateway.save(agentId, path, content); + } + + remove(agentId: string, path: string, recursive: boolean): Promise { + return this.gateway.remove(agentId, path, recursive); + } + + sync(agentId: string): Promise { + return this.gateway.sync(agentId); + } + + exportZip(agentId: string): Promise { + return this.gateway.exportZip(agentId); + } +} diff --git a/admin/slices/agent/file/domain/agentFile.types.ts b/admin/slices/agent/file/domain/agentFile.types.ts new file mode 100644 index 00000000..ec3bc475 --- /dev/null +++ b/admin/slices/agent/file/domain/agentFile.types.ts @@ -0,0 +1,30 @@ +// Domain types for an agent's file store (S3-backed workspace). + +export interface IFileNode { + path: string; + size: number; + updatedAt: string; +} + +export interface IFileContent { + path: string; + content: string; + size: number; + updatedAt: string; +} + +export interface IFileChunk { + path: string; + content: string; + size: number; + totalSize: number; + offset: number; + nextOffset: number | null; + hasMore: boolean; + updatedAt: string; +} + +export interface ISyncResult { + agentOnline: boolean; + pushed: number; +} diff --git a/admin/slices/agent/file/domain/index.ts b/admin/slices/agent/file/domain/index.ts new file mode 100644 index 00000000..757a2a28 --- /dev/null +++ b/admin/slices/agent/file/domain/index.ts @@ -0,0 +1,3 @@ +export * from './agentFile.gateway'; +export * from './agentFile.service'; +export * from './agentFile.types'; diff --git a/admin/slices/agent/file/index.d.ts b/admin/slices/agent/file/index.d.ts new file mode 100644 index 00000000..9ecf0263 --- /dev/null +++ b/admin/slices/agent/file/index.d.ts @@ -0,0 +1,9 @@ +import type { AgentFileService } from './domain/agentFile.service'; + +declare module '#app' { + interface NuxtApp { + $agentFileService: AgentFileService; + } +} + +export {}; diff --git a/admin/slices/agent/file/plugins/di.ts b/admin/slices/agent/file/plugins/di.ts new file mode 100644 index 00000000..7870a2d9 --- /dev/null +++ b/admin/slices/agent/file/plugins/di.ts @@ -0,0 +1,17 @@ +import { AgentFileGateway } from '../data/agentFile.gateway'; +import { AgentFileService } from '../domain/agentFile.service'; + +/** + * Composition root for the agentFile slice. Provides `$agentFileService`. + */ +export default defineNuxtPlugin({ + name: 'agent-file-di', + setup() { + const service = new AgentFileService(new AgentFileGateway()); + return { + provide: { + agentFileService: service, + }, + }; + }, +}); diff --git a/admin/slices/agent/file/stores/agentFile.ts b/admin/slices/agent/file/stores/agentFile.ts index 8e7f0c58..29464dbd 100644 --- a/admin/slices/agent/file/stores/agentFile.ts +++ b/admin/slices/agent/file/stores/agentFile.ts @@ -1,35 +1,21 @@ -import { FilesService } from '#api/data'; - -type ApiEnvelope = { success: boolean; data: T }; - -export interface IFileNode { - path: string; - size: number; - updatedAt: string; -} - -export interface IFileContent { - path: string; - content: string; - size: number; - updatedAt: string; -} - -export interface IFileChunk { - path: string; - content: string; - size: number; - totalSize: number; - offset: number; - nextOffset: number | null; - hasMore: boolean; - updatedAt: string; -} - -export interface ISyncResult { - agentOnline: boolean; - pushed: number; -} +import { createServiceGetter } from '#common/composables/createServiceGetter'; +import type { + AgentFileService, + IFileChunk, + IFileContent, + IFileNode, +} from '#agentFile/domain'; + +// Re-export the domain types so `agentFile/Tree.vue` (and any future consumer +// importing from `#agentFile/stores/agentFile`) keeps working. +export type { + IFileChunk, + IFileContent, + IFileNode, + ISyncResult, +} from '#agentFile/domain'; + +const getService = createServiceGetter('$agentFileService'); // First-chunk size (matches the server default and the old editor cap). // Picked so that small files come back in a single round-trip, while large @@ -86,30 +72,17 @@ export const useAgentFileStore = defineStore('agentFile', () => { } async function fetchList(agentId: string) { - const res = await FilesService.fileControllerList({ path: { agentId } }); - const env = res.data as ApiEnvelope | undefined; - nodes.value = env?.data ?? []; + nodes.value = await getService().list(agentId); return nodes.value; } - async function fetchContent( + function fetchContent( agentId: string, path: string, offset = 0, limit = INITIAL_CHUNK_BYTES, ): Promise { - const res = await FilesService.fileControllerRead({ - path: { agentId }, - query: { path, offset, limit }, - }); - const env = res.data as ApiEnvelope | undefined; - if (!env?.data) { - throw new Error( - (res as { error?: { message?: string } }).error?.message ?? - 'Failed to load file', - ); - } - return env.data; + return getService().read(agentId, path, offset, limit); } async function save( @@ -117,13 +90,7 @@ export const useAgentFileStore = defineStore('agentFile', () => { path: string, content: string, ): Promise { - const res = await FilesService.fileControllerSave({ - path: { agentId }, - query: { path }, - body: { content }, - }); - const env = res.data as ApiEnvelope; - const updated = env.data; + const updated = await getService().save(agentId, path, content); nodes.value = nodes.value.map((n) => n.path === path ? { path: n.path, size: updated.size, updatedAt: updated.updatedAt } @@ -141,11 +108,7 @@ export const useAgentFileStore = defineStore('agentFile', () => { path: string, recursive = false, ): Promise { - const res = await FilesService.fileControllerDelete({ - path: { agentId }, - query: { path, recursive }, - }); - const env = res.data as ApiEnvelope<{ deleted: number }> | undefined; + const deleted = await getService().remove(agentId, path, recursive); const folderPrefix = path.endsWith('/') ? path : path + '/'; nodes.value = nodes.value.filter((n) => recursive @@ -153,32 +116,16 @@ export const useAgentFileStore = defineStore('agentFile', () => { : n.path !== path, ); markPendingRestart(agentId); - return env?.data?.deleted ?? 0; + return deleted; } - async function sync(agentId: string): Promise { - const res = await FilesService.fileControllerSync({ path: { agentId } }); - const env = res.data as ApiEnvelope | undefined; - return env?.data ?? { agentOnline: false, pushed: 0 }; + function sync(agentId: string) { + return getService().sync(agentId); } // Streams the agent's S3 prefix as a ZIP into a browser download. - // Uses raw fetch (not the generated SDK) because the endpoint returns - // raw bytes; we replicate the Bearer-token attachment that the SDK's - // axios interceptor handles automatically — see api/plugins/apiBaseUrl.ts. async function downloadZip(agentId: string): Promise { - const runtime = useRuntimeConfig(); - const headers: Record = {}; - const token = readAccessToken(); - if (token) headers.Authorization = `Bearer ${token}`; - const res = await fetch( - `${runtime.public.apiUrl}/agents/${agentId}/files/export`, - { credentials: 'include', headers }, - ); - if (!res.ok) { - throw new Error(`Download failed: ${res.status} ${res.statusText}`); - } - const blob = await res.blob(); + const blob = await getService().exportZip(agentId); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -189,12 +136,6 @@ export const useAgentFileStore = defineStore('agentFile', () => { URL.revokeObjectURL(url); } - function readAccessToken(): string | null { - if (typeof document === 'undefined') return null; - const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/); - return m ? decodeURIComponent(m[1]) : null; - } - return { nodes, fetchList, diff --git a/admin/slices/agent/secret/components/agentSecret/Provider.vue b/admin/slices/agent/secret/components/agentSecret/Provider.vue index 2fe97c25..4903f074 100644 --- a/admin/slices/agent/secret/components/agentSecret/Provider.vue +++ b/admin/slices/agent/secret/components/agentSecret/Provider.vue @@ -1,9 +1,4 @@